@maci0/dsh-perf-review 0.10.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/LICENSE +661 -0
- package/LICENSE-MIT +21 -0
- package/NOTICE +9 -0
- package/README.md +100 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +5 -0
- package/index.js +381 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -0
- package/skills/perf-review/SKILL.md +122 -0
package/NOTICE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dsh-perf-review
|
|
2
|
+
|
|
3
|
+
The bundled perf-review skill includes material adapted from the perf-review,
|
|
4
|
+
webperf-review, concurrency, resource, database, and cache reviews in
|
|
5
|
+
maci0/gauntlet (https://github.com/maci0/gauntlet), identified in this
|
|
6
|
+
repository as AGPL-3.0 material. The distributed package is AGPL-3.0-only.
|
|
7
|
+
|
|
8
|
+
The original MIT license notice for the DSH adapter is retained in LICENSE-MIT.
|
|
9
|
+
Copyright (c) 2026 dsh-perf-review contributors.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# dsh-perf-review
|
|
2
|
+
|
|
3
|
+
"Make it faster" is a guess. This skill makes the agent measure first.
|
|
4
|
+
|
|
5
|
+
Type `/perf-review` and it profiles the hot path you name, ranks the bottlenecks it finds by user-visible impact, changes one thing, and reports the p50/p95 delta. If it cannot measure a win, it says so instead of shipping one.
|
|
6
|
+
|
|
7
|
+
The whole plugin is one skill: instructions the agent follows, not a profiler. It brings the method and the output format; your existing tools (`perf`, `hyperfine`, `lighthouse`, `heaptrack`, devtools) do the measuring.
|
|
8
|
+
|
|
9
|
+
## What you get
|
|
10
|
+
|
|
11
|
+
- **A resident performance engineer.** The agent gets a role and a success metric (perceived speed) plus hard rules: profile before changing, name the tool and the scenario and the baseline, benchmark every change that claims speed with p50/p95, keep behaviour identical unless a tradeoff is explicit and measured.
|
|
12
|
+
- **A triage order.** Unbounded growth (no pagination, no bound) beats N+1 and redundant work, which beats hot-path allocations and per-iteration compilation, which beats cold-path issues. With no benchmark target, it fixes only categorically safe wins and skips anything whose benefit needs numbers to prove.
|
|
13
|
+
- **Checklists for both halves.** Frontend: FPS, long tasks, input delay, layout thrash, forced reflow, giant unwindowed lists, work that belongs on a worker, compositor-friendly animation, then render-blocking critical path and deferred bytes. Backend: CPU and allocation profiles, cache misses, SoA over AoS, arena reuse, batched I/O, auto-vectorization blockers checked before anyone reaches for intrinsics.
|
|
14
|
+
- **Deterministic perf tests, not flaky ones.** Every claimed win has to leave a test that still passes on a loaded machine: retired instructions or work counters first, then CPU time, then hardware-counter ratios. Wall clock is for the product-level p50/p95 and a coarse bound only; a wall-clock gate needs medians and a tolerance band, and says so.
|
|
15
|
+
- **Runtime currency check.** It notes the version the code actually runs on and checks recent releases for speedups that touch the hot paths found, and recommends an upgrade only where a measured path gains.
|
|
16
|
+
- **Named ownership boundaries.** It judges whether a cache should exist and owns app-side query call sites; it never owns schema or migrations. It will not trade correctness, accessibility, or content for speed.
|
|
17
|
+
- **A fixed report shape.** Bottlenecks ranked by user-visible impact with confidence (confirmed / likely / potential), changes made with measured deltas, remaining hot paths, and what it refused to do because it was unmeasured or would not help.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
> **Install it as a bundle.** `dsh plugin add …` mounts the row from the
|
|
22
|
+
> package's own patch layer, which is what the settings editor can write to. A
|
|
23
|
+
> row added with `--patch` is an overlay: it disappears at the next start.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
dsh plugin --profile web add @maci0/dsh-perf-review@0.10.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
|
|
30
|
+
The version is pinned. To upgrade, run the same command with a newer version,
|
|
31
|
+
then restart `dsh web` (bundle layers compose at boot).
|
|
32
|
+
|
|
33
|
+
The package declares `dsh.bundle.patch`, so the CLI appends it to `dsh.profile.bundles` and its shipped `cordis.patch.yml` supplies the row. Do not paste that row into `~/.dsh/profiles/web/cordis.patch.yml` as well: the bundle layer already applies it, and a second row registers the plugin twice.
|
|
34
|
+
|
|
35
|
+
Uninstall with the package name:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dsh plugin --profile web remove @maci0/dsh-perf-review
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That drops the dependency and the bundle layer with it; nothing else to edit.
|
|
42
|
+
|
|
43
|
+
## Use it
|
|
44
|
+
|
|
45
|
+
Type the lag, not the fix:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/perf-review the file tree stalls for a beat when I expand a folder, profile it and tell me what to change
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The agent then states the user-visible lag it is attacking, shows profile evidence (hot function, % time, scenario), proposes the smallest change that hits that hot path, implements it, re-runs the same scenario before and after, and keeps or reverts on the numbers.
|
|
52
|
+
|
|
53
|
+
Give it a repo path, a trace, or a running local URL and it works from what exists. For a browser measurement it uses static files or an already-listening local URL. It never installs tools, never starts a server to get a measurement, and never hits a remote host. With no codebase in reach it first lists the exact files, traces, and benchmarks it needs.
|
|
54
|
+
|
|
55
|
+
## Configure
|
|
56
|
+
|
|
57
|
+
No config fields. The plugin reads one bundled skill and mounts it; behaviour is the skill text itself.
|
|
58
|
+
|
|
59
|
+
| Frontmatter key | Effect |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `name` | Skill id: `perf-review`, so the composer exposes `/perf-review`. |
|
|
62
|
+
| `description` | What the model sees when deciding to load the skill. |
|
|
63
|
+
|
|
64
|
+
Both invocation policies are always on (the provider emits `invocation: { modelInvocable: true, userInvocable: true }`), and any other frontmatter key is parsed and ignored.
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
`index.js` is plain JavaScript, no build step. It hooks `ctx.skills.registerProvider()`, resolves its `skills/` directory with `fileURLToPath`, and reads the one bundled `skills/perf-review/SKILL.md` directly. Candidate summaries carry `rank: BUNDLED_SKILL_RANK`, so a project or user skill of the same name still takes precedence.
|
|
69
|
+
|
|
70
|
+
Frontmatter is parsed with `yaml`, the same parser the harness's own filesystem skill provider uses, so plain scalars, `|`/`|-`/`>-` block scalars, and nested maps read as YAML says they do. An invalid skill name or a description-less file is skipped with a warning, never fatal. A missing root or a refused `SKILL.md` is reported as an **incomplete observation**, not an empty catalog, so the registry cannot cache a failed read as "no skills here".
|
|
71
|
+
|
|
72
|
+
Runtime dependencies: `@deepseek-ai/dsh-skill` and `yaml`, both declared in `package.json`.
|
|
73
|
+
|
|
74
|
+
## Limits
|
|
75
|
+
|
|
76
|
+
- It is a skill plus instructions, not a profiler. Every number comes from a tool you already have; if nothing can measure the path, the change does not ship.
|
|
77
|
+
- It does not own schema, indexes, or migrations, and it does not own caching correctness, only whether a cache should exist at all.
|
|
78
|
+
- It adds no model tool, no slash command, and no browser half. A skill needs none of that.
|
|
79
|
+
- The composer exposes user-invocable skills as `/<name>` on its own; this package does not draw UI.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
bun install --frozen-lockfile
|
|
85
|
+
bun test # tests/*.test.js: 60 tests, no build step
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun. Tests cover the frontmatter parser, discovery, the provider's `list`/`get` contract, abort handling, incomplete-root reporting, and a real Cordis composition that mounts and disposes the provider.
|
|
89
|
+
|
|
90
|
+
For local development, install the checkout as a bundle:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
dsh plugin --profile <name> add <path-to-checkout>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Licence
|
|
97
|
+
|
|
98
|
+
AGPL-3.0-only. The bundled skill includes material adapted from
|
|
99
|
+
[maci0/gauntlet](https://github.com/maci0/gauntlet). See `LICENSE` and `NOTICE`.
|
|
100
|
+
The original MIT notice for the DSH adapter is retained in `LICENSE-MIT`.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The dsh-perf-review bundle patch: applied automatically when a profile lists
|
|
2
|
+
# this bundle (`dsh plugin add`/`update` appends the package to
|
|
3
|
+
# dsh.profile.bundles). Users override this row from their profile's own
|
|
4
|
+
# cordis.patch.yml (live-watched; dsh.profile.bundles is frozen at boot) with a
|
|
5
|
+
# `- id: perf-review` row, which replaces the row's whole `config`.
|
|
6
|
+
# Do not also insert this same row into the profile patch: insert does not
|
|
7
|
+
# dedupe ids, and a second row would register the plugin twice.
|
|
8
|
+
- insert:
|
|
9
|
+
- id: perf-review
|
|
10
|
+
name: '@maci0/dsh-perf-review'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M8 23.5a10 10 0 1 1 20 0" stroke="#F2AF63" stroke-width="2.4" stroke-linecap="round"/>
|
|
3
|
+
<path d="M18 23.5L24.2 15" stroke="#C47B2B" stroke-width="2.2" stroke-linecap="round"/>
|
|
4
|
+
<circle cx="18" cy="23.5" r="2.1" fill="#C47B2B"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-perf-review: performance review skill for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* One capability: the bundled `perf-review` skill becomes a `ctx.skills`
|
|
5
|
+
* provider, so it loads through the `skill` tool and appears as
|
|
6
|
+
* `/perf-review` in the composer. No settings, no tool, no command, no
|
|
7
|
+
* browser half: a skill needs none of that.
|
|
8
|
+
*
|
|
9
|
+
* Skill content: the user's perf prompt, plus the hot-path/SIMD/data-layout
|
|
10
|
+
* material from gauntlet's perf-review and the critical-path/delivery
|
|
11
|
+
* material from its webperf-review (maci0/gauntlet), plus fix-order and
|
|
12
|
+
* ownership-boundary lines from the adjacent concurrency/resource/db/cache
|
|
13
|
+
* reviews.
|
|
14
|
+
*
|
|
15
|
+
* `package.json` declares `dsh.bundle.patch`, so `dsh plugin add` mounts this
|
|
16
|
+
* package as a profile layer and cordis.patch.yml supplies the row.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from 'node:fs/promises'
|
|
20
|
+
import { basename, dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { BUNDLED_SKILL_RANK, isSkillName } from '@deepseek-ai/dsh-skill'
|
|
23
|
+
|
|
24
|
+
/** Plugin name as it appears in the loader. */
|
|
25
|
+
export const name = 'perf-review'
|
|
26
|
+
|
|
27
|
+
/** Service this plugin needs; `ctx.skills` is ready when `apply` runs. */
|
|
28
|
+
export const inject = ['skills']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read and parse one skill file. Shared by discovery and direct loads so a
|
|
32
|
+
* single file enforces the name/description/frontmatter rules everywhere.
|
|
33
|
+
*/
|
|
34
|
+
async function readSkillFile(path, onWarn, entryName, signal) {
|
|
35
|
+
if (signal?.aborted) return undefined
|
|
36
|
+
|
|
37
|
+
let source
|
|
38
|
+
try {
|
|
39
|
+
source = await readFile(path, { encoding: 'utf8', signal })
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// An aborted read is the caller withdrawing, not a broken skill.
|
|
42
|
+
if (!signal?.aborted) onWarn?.(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let parsed
|
|
47
|
+
try {
|
|
48
|
+
// The flat reader covers every header this package ships. Its refusal is
|
|
49
|
+
// what selects the real YAML parser, so the fallback stays the contract.
|
|
50
|
+
parsed = parseFrontmatter(source)
|
|
51
|
+
if (parsed === undefined) parsed = await parseFrontmatterWithYaml(source)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
onWarn?.(`skipping ${path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fallback = entryName ?? basename(path)
|
|
58
|
+
const skillName = String(parsed.data.name ?? fallback).trim()
|
|
59
|
+
const description = String(parsed.data.description ?? '').trim()
|
|
60
|
+
if (!isSkillName(skillName)) {
|
|
61
|
+
onWarn?.(`skipping ${path}: "${skillName}" is not a valid kebab-case skill name`)
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
if (description === '') {
|
|
65
|
+
onWarn?.(`skipping ${path}: frontmatter has no description`)
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: skillName,
|
|
71
|
+
description,
|
|
72
|
+
content: parsed.body.trim(),
|
|
73
|
+
path,
|
|
74
|
+
directory: dirname(path),
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `key: value` at column zero, with nothing but horizontal space around the colon. */
|
|
79
|
+
const ENTRY = /^([^\s:#][^\s:#]*?)[ \t]*:([ \t]+[^\r\n]*|)$/
|
|
80
|
+
/** A plain scalar with no leading indicator, no `#`, no `: ` mapping, no reserved start. */
|
|
81
|
+
const PLAIN = /^[^\s!&*\-?{}[\],#|>@`"'%:][^#:]*$/
|
|
82
|
+
/** A double-quoted scalar with no backslash escape in it. */
|
|
83
|
+
const SIMPLE_DOUBLE = /^[^\\]*$/
|
|
84
|
+
/** A single-quoted scalar with no `''` escape in it. */
|
|
85
|
+
const SIMPLE_SINGLE = /^[^']*$/
|
|
86
|
+
/** The block scalar header this reader reads: style plus an optional strip flag. */
|
|
87
|
+
const BLOCK_HEADER = /^([|>])(-)?$/
|
|
88
|
+
/** A plain scalar YAML types as an integer: `0x10`, `0o17`, `+5`, `-0`, `007`, `1_000`. */
|
|
89
|
+
const TYPED_INT = /^[-+]?(?:0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|[0-9][0-9_]*)$/
|
|
90
|
+
/** A plain scalar YAML types as a special float: `.inf`, `.nan`, either sign, any case. */
|
|
91
|
+
const TYPED_SPECIAL = /^[-+]?\.(?:inf|nan)$/i
|
|
92
|
+
/** A plain scalar YAML types as a float: a leading `+`, leading zeros, a `.5`/`1.` body. */
|
|
93
|
+
const TYPED_FLOAT = /^(?:[-+]?[0-9][0-9_]*\.[0-9_]*|[-+]?\.[0-9][0-9_]*)$/i
|
|
94
|
+
/** The decimal scalars this reader converts itself, with no YAML-only spelling. */
|
|
95
|
+
const SAFE_INT = /^-?(?:0|[1-9]\d*)$/
|
|
96
|
+
const SAFE_FLOAT = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][-+]?\d+)?$/
|
|
97
|
+
/** Keys YAML resolves to a non-string: `null` becomes `''` and `True` becomes `'true'`. */
|
|
98
|
+
const RESOLVED_KEY = /^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE)$/
|
|
99
|
+
/** Keys that would not survive `data[key] = value` on an object literal. */
|
|
100
|
+
const UNSAFE_KEY = new Set(['__proto__'])
|
|
101
|
+
/** A key this reader can prove `yaml` resolves to the same string. */
|
|
102
|
+
const SAFE_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/
|
|
103
|
+
/** Code points JS `trim` strips but YAML counts as content: indentation is unprovable. */
|
|
104
|
+
const JS_ONLY_SPACE = /[\u000B\u000C\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/
|
|
105
|
+
|
|
106
|
+
/** The delimiter line, exactly as the pre-change reader tested it. */
|
|
107
|
+
const DELIMITER = /^---[ \t]*$/
|
|
108
|
+
|
|
109
|
+
/** Split a data block into lines. `parseFrontmatter` refuses any `\r`, so `\n` is the only break. */
|
|
110
|
+
function toLines(text) {
|
|
111
|
+
return text.split('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Split one document into its leading frontmatter block and the body, the way
|
|
116
|
+
* the pre-change reader did: line by line, so the string handed to `yaml` is
|
|
117
|
+
* byte-identical to what it parsed before the fast path existed.
|
|
118
|
+
* @param {string} source - the file's contents.
|
|
119
|
+
* @returns {{ block: string, body: string, present: boolean }} the parts.
|
|
120
|
+
*/
|
|
121
|
+
function splitDocument(source) {
|
|
122
|
+
const text = String(source).replace(/^\uFEFF/, '')
|
|
123
|
+
const lines = text.split(/\r?\n/)
|
|
124
|
+
if (lines[0] === undefined || !DELIMITER.test(lines[0])) return { block: '', body: text, present: false }
|
|
125
|
+
let closing = -1
|
|
126
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
127
|
+
if (DELIMITER.test(lines[index] ?? '')) {
|
|
128
|
+
closing = index
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (closing === -1) return { block: '', body: text, present: false }
|
|
133
|
+
return { block: lines.slice(1, closing).join('\n'), body: lines.slice(closing + 1).join('\n'), present: true }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Count the spaces a line starts with. YAML indentation is spaces; a tab or a
|
|
138
|
+
* code point JS treats as blank is not this reader's to interpret.
|
|
139
|
+
* @param {string} line - one line of the block.
|
|
140
|
+
* @returns {number} the number of leading spaces.
|
|
141
|
+
*/
|
|
142
|
+
function leadingSpaces(line) {
|
|
143
|
+
let count = 0
|
|
144
|
+
while (count < line.length && line.charCodeAt(count) === 32) count += 1
|
|
145
|
+
return count
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read one block scalar: same-indent lines only, chomping as YAML defines it.
|
|
150
|
+
* @returns the scalar and the index one past the block, or `undefined` when the
|
|
151
|
+
* block has a deeper-indented line, an interior blank line, or no content.
|
|
152
|
+
*/
|
|
153
|
+
function readBlockScalar(lines, header, headerValue) {
|
|
154
|
+
const style = headerValue[0]
|
|
155
|
+
const modifier = headerValue.slice(1)
|
|
156
|
+
const first = lines[header + 1]
|
|
157
|
+
if (first === undefined) return undefined
|
|
158
|
+
// Indentation is spaces only: a tab is a YAML parse error there, so a
|
|
159
|
+
// tab-led or unindented first line leaves the block to `yaml`.
|
|
160
|
+
const indent = leadingSpaces(first)
|
|
161
|
+
if (indent === 0) return undefined
|
|
162
|
+
|
|
163
|
+
const content = []
|
|
164
|
+
let index = header + 1
|
|
165
|
+
let closed = false
|
|
166
|
+
for (; index < lines.length; index += 1) {
|
|
167
|
+
const line = lines[index]
|
|
168
|
+
if (leadingSpaces(line) < indent) { closed = true; break }
|
|
169
|
+
if (line.length === indent) {
|
|
170
|
+
// An interior blank line folds differently; only a trailing run may stay.
|
|
171
|
+
if (index + 1 < lines.length && lines[index + 1].length >= indent) return undefined
|
|
172
|
+
content.push('')
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
if (leadingSpaces(line) > indent) return undefined
|
|
176
|
+
if (line.charCodeAt(indent) === 9) return undefined
|
|
177
|
+
content.push(line.slice(indent, line.length))
|
|
178
|
+
}
|
|
179
|
+
// A block that runs to the end of the document is closed there.
|
|
180
|
+
if (!closed && index >= lines.length) closed = true
|
|
181
|
+
if (!closed) return undefined
|
|
182
|
+
if (content.length === 0) return undefined
|
|
183
|
+
|
|
184
|
+
let last = content.length
|
|
185
|
+
while (last > 0 && content[last - 1] === '') last -= 1
|
|
186
|
+
const kept = content.slice(0, last)
|
|
187
|
+
if (kept.length === 0) return undefined
|
|
188
|
+
|
|
189
|
+
const body = style === '|' ? kept.join('\n') : kept.join(' ')
|
|
190
|
+
if (modifier.includes('-')) return { value: body, next: index }
|
|
191
|
+
return { value: `${body}\n`, next: index }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read one scalar value.
|
|
196
|
+
* @returns the value, or `undefined` when it needs the real YAML parser.
|
|
197
|
+
*/
|
|
198
|
+
function readScalar(raw) {
|
|
199
|
+
// A `#` needs YAML's comment rules (one only after whitespace) to read: refuse.
|
|
200
|
+
if (raw.includes('#')) return undefined
|
|
201
|
+
// `.inf` / `.nan` are YAML's special floats, in any case and either sign.
|
|
202
|
+
if (TYPED_SPECIAL.test(raw)) return undefined
|
|
203
|
+
if (raw === '' || raw === '~' || raw === 'null' || raw === 'Null' || raw === 'NULL') return { value: null }
|
|
204
|
+
if (raw === 'true' || raw === 'True' || raw === 'TRUE') return { value: true }
|
|
205
|
+
if (raw === 'false' || raw === 'False' || raw === 'FALSE') return { value: false }
|
|
206
|
+
if (/[0-9]/.test(raw)) {
|
|
207
|
+
// A digit anywhere means YAML may type this scalar; only the spellings this
|
|
208
|
+
// reader converts identically may pass, every other form goes to `yaml`.
|
|
209
|
+
if (SAFE_INT.test(raw) || SAFE_FLOAT.test(raw)) return { value: Number(raw) }
|
|
210
|
+
// Any other exponent spelling is YAML's floatExp, whose mantissa may be
|
|
211
|
+
// `.5`, `1.`, or zero-padded (`01e9`, `00e0`), none of which this reader
|
|
212
|
+
// converts, so it must not claim the block.
|
|
213
|
+
if (/[eE]/.test(raw)) return undefined
|
|
214
|
+
if (TYPED_INT.test(raw) || TYPED_FLOAT.test(raw) || /^[-+]/.test(raw) || raw.includes('_')) return undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const first = raw.charCodeAt(0)
|
|
218
|
+
if (first === 34) {
|
|
219
|
+
if (!SIMPLE_DOUBLE.test(raw.slice(1))) return undefined
|
|
220
|
+
const closing = raw.indexOf('"', 1)
|
|
221
|
+
if (closing === -1 || raw.slice(closing + 1).trim() !== '') return undefined
|
|
222
|
+
return { value: raw.slice(1, closing) }
|
|
223
|
+
}
|
|
224
|
+
if (first === 39) {
|
|
225
|
+
if (!SIMPLE_SINGLE.test(raw.slice(1))) return undefined
|
|
226
|
+
const closing = raw.indexOf("'", 1)
|
|
227
|
+
if (closing === -1 || raw.slice(closing + 1).trim() !== '') return undefined
|
|
228
|
+
return { value: raw.slice(1, closing) }
|
|
229
|
+
}
|
|
230
|
+
if (!PLAIN.test(raw)) return undefined
|
|
231
|
+
return { value: raw }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Read a flat block of `key: value` entries.
|
|
236
|
+
* @returns the mapping, or `undefined` when any line needs the real YAML parser.
|
|
237
|
+
*/
|
|
238
|
+
function parseFlatBlock(block) {
|
|
239
|
+
// `trim`/`trimStart` in this reader would measure indentation through these
|
|
240
|
+
// and YAML would not: the real parser has to decide.
|
|
241
|
+
if (JS_ONLY_SPACE.test(block)) return undefined
|
|
242
|
+
const lines = toLines(block)
|
|
243
|
+
const data = {}
|
|
244
|
+
const seen = new Set()
|
|
245
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
246
|
+
const line = lines[index]
|
|
247
|
+
if (line === '' || line.charCodeAt(0) === 35) continue
|
|
248
|
+
const entry = ENTRY.exec(line)
|
|
249
|
+
if (entry === null) return undefined
|
|
250
|
+
|
|
251
|
+
const key = entry[1].trimEnd()
|
|
252
|
+
if (key !== entry[1]) return undefined
|
|
253
|
+
// Only a key that is provably its own string: that excludes quoted keys,
|
|
254
|
+
// flow keys, keys starting with an indicator (`@a`, `|a`, `[a]`), typed
|
|
255
|
+
// keys (`0x10`), and the words YAML resolves to `null`/`true`/`false`.
|
|
256
|
+
if (!SAFE_KEY.test(key)) return undefined
|
|
257
|
+
if (RESOLVED_KEY.test(key)) return undefined
|
|
258
|
+
// `yaml` rejects a duplicate key and a `__proto__` key does not survive a
|
|
259
|
+
// plain object assignment; both need the real parser.
|
|
260
|
+
if (seen.has(key) || UNSAFE_KEY.has(key)) return undefined
|
|
261
|
+
seen.add(key)
|
|
262
|
+
const raw = entry[2].replace(/^[ \t]+/, '').replace(/[ \t]+$/, '')
|
|
263
|
+
if (raw === '') {
|
|
264
|
+
// A value on following lines is a nested map or a sequence.
|
|
265
|
+
const next = lines[index + 1]
|
|
266
|
+
if (next !== undefined && next.trimStart() !== '' && next.charCodeAt(0) !== 35) return undefined
|
|
267
|
+
data[key] = null
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
if (raw.charCodeAt(0) === 124 || raw.charCodeAt(0) === 62) {
|
|
271
|
+
// Only clip and strip chomping are read here; `+` keeps every trailing
|
|
272
|
+
// line break, which this line-based reader does not count.
|
|
273
|
+
if (BLOCK_HEADER.exec(raw) === null) return undefined
|
|
274
|
+
const scalar = readBlockScalar(lines, index, raw)
|
|
275
|
+
if (scalar === undefined) return undefined
|
|
276
|
+
data[key] = scalar.value
|
|
277
|
+
index = scalar.next - 1
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
const scalar = readScalar(raw)
|
|
281
|
+
if (scalar === undefined) return undefined
|
|
282
|
+
data[key] = scalar.value
|
|
283
|
+
}
|
|
284
|
+
return data
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parse leading frontmatter from a markdown document, for the flat subset only.
|
|
289
|
+
* @param {string} source - full file contents.
|
|
290
|
+
* @returns {{ data: Record<string, unknown>, body: string }|undefined} the read,
|
|
291
|
+
* or `undefined` when the block needs the real YAML parser.
|
|
292
|
+
*/
|
|
293
|
+
export function parseFrontmatter(source) {
|
|
294
|
+
// `\r` is a line break to YAML and not to this reader: hand the whole file,
|
|
295
|
+
// CRLF included, to the real parser instead of claiming the block.
|
|
296
|
+
if (String(source).includes('\r')) return undefined
|
|
297
|
+
const { block, body, present } = splitDocument(source)
|
|
298
|
+
if (!present) return { data: {}, body }
|
|
299
|
+
const data = parseFlatBlock(block)
|
|
300
|
+
if (data === undefined) return undefined
|
|
301
|
+
return { data, body }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Parse leading `---` frontmatter with `yaml`, the parser the harness's own
|
|
306
|
+
* filesystem skill provider uses. Frontmatter with no closing `---` is not
|
|
307
|
+
* frontmatter, and the body is then the whole source. The extraction is the
|
|
308
|
+
* pre-change reader's line-by-line split, so `yaml` sees the same bytes.
|
|
309
|
+
* @param {string} source - full file contents.
|
|
310
|
+
* @returns {Promise<{ data: Record<string, unknown>, body: string }>} the read.
|
|
311
|
+
*/
|
|
312
|
+
export async function parseFrontmatterWithYaml(source) {
|
|
313
|
+
const { block, body, present } = splitDocument(source)
|
|
314
|
+
if (!present) return { data: {}, body }
|
|
315
|
+
const { parse: parseYaml } = await import('yaml')
|
|
316
|
+
const parsed = parseYaml(block)
|
|
317
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
318
|
+
// Empty or non-mapping frontmatter: no keys, but the body still loads.
|
|
319
|
+
return { data: {}, body }
|
|
320
|
+
}
|
|
321
|
+
return { data: parsed, body }
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Read this package's one bundled skill. No directory walk: the package ships
|
|
325
|
+
* exactly one `SKILL.md`, and `get()` already loads a locator directly.
|
|
326
|
+
* @returns `{ candidates, complete }`; `complete: false` means re-read, not "no skills".
|
|
327
|
+
*/
|
|
328
|
+
export async function discoverSkills(skillsDir, onWarn, signal) {
|
|
329
|
+
if (signal?.aborted) return { candidates: [], complete: false }
|
|
330
|
+
const skill = await readSkillFile(join(skillsDir, name, 'SKILL.md'), onWarn, name, signal)
|
|
331
|
+
// A failed read is an incomplete observation, so the registry re-reads
|
|
332
|
+
// instead of caching a broken skill as an empty catalog.
|
|
333
|
+
return skill === undefined ? { candidates: [], complete: false } : { candidates: [skill], complete: true }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Summary for one bundled skill. Every SKILL.md this package ships is invocable by both the model and the user. */
|
|
337
|
+
function summaryOf(skill) {
|
|
338
|
+
return {
|
|
339
|
+
path: skill.path,
|
|
340
|
+
name: skill.name,
|
|
341
|
+
description: skill.description,
|
|
342
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
343
|
+
source: 'bundled',
|
|
344
|
+
provider: 'perf-review',
|
|
345
|
+
resourceBase: { kind: 'directory', path: skill.directory },
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Build the provider the skill registry mounts. */
|
|
350
|
+
export function createSkillProvider({ skillsDir, onWarn }) {
|
|
351
|
+
return {
|
|
352
|
+
name: 'perf-review',
|
|
353
|
+
async list(lookup) {
|
|
354
|
+
const { candidates, complete } = await discoverSkills(skillsDir, onWarn, lookup?.signal)
|
|
355
|
+
const skills = candidates.map((skill) => ({ ...summaryOf(skill), rank: BUNDLED_SKILL_RANK, locator: skill.path }))
|
|
356
|
+
// Array shorthand on a complete read; an explicit observation otherwise, so the registry cannot cache a failed read as an empty catalog.
|
|
357
|
+
return complete ? skills : { candidates: skills, complete: false }
|
|
358
|
+
},
|
|
359
|
+
async get(candidate, lookup) {
|
|
360
|
+
if (typeof candidate.locator !== 'string') return undefined
|
|
361
|
+
// Read the locator directly: one file instead of a full re-discovery.
|
|
362
|
+
// The directory name is the fallback identity a name-less SKILL.md is
|
|
363
|
+
// listed under; the name check keeps a stale candidate (path reused by
|
|
364
|
+
// another skill) from loading under the wrong identity.
|
|
365
|
+
const skill = await readSkillFile(candidate.locator, onWarn, basename(dirname(candidate.locator)), lookup?.signal)
|
|
366
|
+
if (skill === undefined || skill.name !== candidate.name) return undefined
|
|
367
|
+
return { ...summaryOf(skill), content: skill.content }
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Mount the plugin: skills provider only. `inject` above already made the fiber wait for `ctx.skills`. */
|
|
373
|
+
export function apply(ctx) {
|
|
374
|
+
const warn = (message) => {
|
|
375
|
+
if (ctx.logger?.warn) ctx.logger.warn(`[perf-review] ${message}`)
|
|
376
|
+
else console.warn(`[perf-review] ${message}`)
|
|
377
|
+
}
|
|
378
|
+
ctx.skills.registerProvider(() =>
|
|
379
|
+
createSkillProvider({ skillsDir: fileURLToPath(new URL('./skills', import.meta.url)), onWarn: warn }),
|
|
380
|
+
)
|
|
381
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maci0/dsh-perf-review",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public",
|
|
6
|
+
"registry": "https://registry.npmjs.org/"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"description": "Performance review skill for DeepSeek Harness: perceived speed above all: profile before changing, benchmark every claim, keep behavior identical.",
|
|
11
|
+
"license": "AGPL-3.0-only",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./index.js",
|
|
14
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
15
|
+
"./locale/*.json": "./locale/*.json",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"dsh": {
|
|
19
|
+
"bundle": {
|
|
20
|
+
"patch": "./cordis.patch.yml"
|
|
21
|
+
},
|
|
22
|
+
"compatibility": {
|
|
23
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"locale/*.json",
|
|
29
|
+
"index.js",
|
|
30
|
+
"skills",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"LICENSE-MIT",
|
|
35
|
+
"NOTICE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "bun test",
|
|
39
|
+
"test:node": "node --test tests/*.test.*"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@deepseek-ai/dsh-skill": "0.2.1-alpha.1",
|
|
46
|
+
"yaml": "2.9.1"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@deepseek-ai/cordis": "4.0.5-alpha.1",
|
|
50
|
+
"@deepseek-ai/dsh-scope": "0.2.1-alpha.1"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/maci0/dsh-perf-review.git"
|
|
55
|
+
},
|
|
56
|
+
"icon": "./icon.svg"
|
|
57
|
+
}
|