@lmzhen/dsh-evolution-core 0.26.26 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- package/package.json +39 -11
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter parsing, normalization and validation for skill Markdown files.
|
|
3
|
+
*
|
|
4
|
+
* Split out of skill-store.ts (S2-1): pure functions over file text, no store
|
|
5
|
+
* state. skill-store.ts re-exports the same names it exported before the split,
|
|
6
|
+
* so the package export surface is unchanged.
|
|
7
|
+
*/
|
|
8
|
+
// S2.1 (PLAN 2026-09-16, audit P2-1): the `yaml` package — the SAME dependency
|
|
9
|
+
// the upstream platform catalog uses (skill-filesystem: `import { parse as
|
|
10
|
+
// parseYaml } from 'yaml'`, `yaml: ^2.4.2`) — replaces js-yaml. js-yaml speaks
|
|
11
|
+
// YAML 1.1 full schema while the platform speaks YAML 1.2 core, so the family
|
|
12
|
+
// and the platform read different values out of the SAME bytes: a plain
|
|
13
|
+
// `description: 2026-09-16` was a Date here (rejected by the string-contract
|
|
14
|
+
// plumbing) and the literal string there; the reverse split (`+.inf` written
|
|
15
|
+
// unquoted) made the platform read a float and ignore a whole file the family
|
|
16
|
+
// listed. Verified against js-yaml 4 by the PLAN S2.1 (2026-09-16)
|
|
17
|
+
// pair-compare: the value-level differences are
|
|
18
|
+
// confined to dates/timestamps and the 1.1 binary-int form (`0b101`); 1.1
|
|
19
|
+
// bool words (yes/no/on/off), hex/octal/leading-zero ints, exponent floats
|
|
20
|
+
// and .inf/.nan already read identically, and both parsers throw on the
|
|
21
|
+
// same unloadable blocks (duplicate keys included), so the lenient fallback
|
|
22
|
+
// verdicts are unchanged.
|
|
23
|
+
import { parse as parseYaml } from 'yaml';
|
|
24
|
+
import { AUTHORING_DESCRIPTION_BAR, CONTENT_SPLIT_HINT, SKILL_NAME_RE } from "./constants.js";
|
|
25
|
+
import { isRecord } from "./guards.js";
|
|
26
|
+
import { DEFAULT_SKILL_LIMITS } from "./limits.js";
|
|
27
|
+
/**
|
|
28
|
+
* Shared frontmatter block detection (P3-3 single owner): opening line `---`
|
|
29
|
+
* and closing line exactly `---`. Used by `parseFrontmatter`,
|
|
30
|
+
* `frontmatterCatalogInvalid` and `normalizeFrontmatter` so the three can
|
|
31
|
+
* never disagree about where the block ends (the loose `indexOf('\n---')`
|
|
32
|
+
* form matched `\n----` and was replaced by this strict line rule).
|
|
33
|
+
*
|
|
34
|
+
* V27 G2.1: both fence lines are matched EXACTLY, tolerating only a trailing
|
|
35
|
+
* `\r` — the same rule the upstream filesystem catalog uses
|
|
36
|
+
* (`skill-filesystem.parseFrontmatter`). The former `.trim()` comparison
|
|
37
|
+
* accepted ` --- `, so an indented fence loaded in the family while the
|
|
38
|
+
* platform ignored the file: family visibility split from platform visibility,
|
|
39
|
+
* which is exactly what a strict-YAML frontmatter is supposed to prevent.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* S1.1 (v37 P1-4): the frontmatter block is split on LF and every line keeps its
|
|
43
|
+
* own `\r`, so the byte-preserving rebuild (`lines.join('\n')`) is exact. Line
|
|
44
|
+
* MATCHERS must therefore look at a CR-stripped view: JS `.` does not match `\r`,
|
|
45
|
+
* so `(.*)$` never reaches the end of a CRLF line.
|
|
46
|
+
* @param line - one block line, possibly CR-terminated.
|
|
47
|
+
* @returns the line without its trailing CR.
|
|
48
|
+
*/
|
|
49
|
+
function withoutCr(line) {
|
|
50
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
51
|
+
}
|
|
52
|
+
export function frontmatterBlock(content) {
|
|
53
|
+
if (!content.trimStart().startsWith('---'))
|
|
54
|
+
return null;
|
|
55
|
+
// S1.1 (v37 P1-4): the block is located by splitting on '\n' and tolerating a
|
|
56
|
+
// trailing '\r' on every line — the rule upstream `skill-filesystem` uses. The
|
|
57
|
+
// old "the whole file picks ONE newline style" test made a MIXED-ending file
|
|
58
|
+
// (LF first line plus any CRLF later: PowerShell Add-Content, an editor, a git
|
|
59
|
+
// hunk conversion) look like a file with NO frontmatter — every family write was
|
|
60
|
+
// refused with a misleading message while the platform read the same bytes fine.
|
|
61
|
+
// Lines keep their '\r', so joining them with '\n' reproduces the original bytes;
|
|
62
|
+
// `nl` reports the file's FIRST line ending and is used only to rebuild text.
|
|
63
|
+
const firstBreak = content.indexOf('\n');
|
|
64
|
+
const nl = firstBreak > 0 && content[firstBreak - 1] === '\r' ? '\r\n' : '\n';
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
const opening = (lines[0] ?? '').replace(/\r$/, '');
|
|
67
|
+
if (opening !== '---')
|
|
68
|
+
return null;
|
|
69
|
+
let end = -1;
|
|
70
|
+
for (let i = 1; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
if (line === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
if (line === '---' || line === '---\r') {
|
|
75
|
+
end = i;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (end < 0)
|
|
80
|
+
return null;
|
|
81
|
+
return { block: lines.slice(1, end).join('\n'), lines, end, nl };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Raw-line scan of a frontmatter block: the single owner of "which entries the
|
|
85
|
+
* strict catalog cannot load". `frontmatterCatalogInvalid` publishes it and
|
|
86
|
+
* `normalizeFrontmatter` decides each rewrite with the same predicate
|
|
87
|
+
* (`yamlPlainScalarNeedsQuotes`), so the audit verdict and the write path can
|
|
88
|
+
* never disagree. A9 (audit P2-12): the scan is ENDING-AGNOSTIC — lines are
|
|
89
|
+
* split on `\n` with the per-line CR stripped, exactly the iteration the
|
|
90
|
+
* rewrite path uses. The former split on the block's FIRST-LINE ending turned
|
|
91
|
+
* a mixed-ending block (first line CRLF, entries LF) into one unsplit chunk
|
|
92
|
+
* whose entries were all skipped, so an unquoted ` #` value read as CLEAN
|
|
93
|
+
* while the catalog silently dropped it and the next edit would have quoted
|
|
94
|
+
* it. Only single-line `key: value` entries are judged.
|
|
95
|
+
*/
|
|
96
|
+
function unsafeFrontmatterEntries(block) {
|
|
97
|
+
const found = [];
|
|
98
|
+
for (const rawLine of block.split('\n')) {
|
|
99
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(withoutCr(rawLine));
|
|
100
|
+
if (!match)
|
|
101
|
+
continue;
|
|
102
|
+
const key = match[1];
|
|
103
|
+
if (key === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
const value = (match[2] ?? '').trim();
|
|
106
|
+
if (yamlPlainScalarNeedsQuotes(value))
|
|
107
|
+
found.push({ key, value });
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Frontmatter fields the platform catalog reads as STRINGS (`stringField` /
|
|
113
|
+
* `optionalString` in `skill-filesystem`): any other YAML type reads as
|
|
114
|
+
* ABSENT there, and an absent `name`/`description` makes the catalog ignore the
|
|
115
|
+
* whole file. The family publishes text for these keys anyway (its lenient
|
|
116
|
+
* read), which is the one split a write must not create.
|
|
117
|
+
*/
|
|
118
|
+
const PLATFORM_STRING_FIELDS = ['name', 'description', 'whenToUse'];
|
|
119
|
+
/**
|
|
120
|
+
* Frontmatter values as the STRICT platform catalog reads them — the `yaml`
|
|
121
|
+
* package (YAML 1.2 core schema, the same dependency the platform's
|
|
122
|
+
* skill-filesystem parses with — see the import note above),
|
|
123
|
+
* the parser `normalizeFrontmatter` also verifies rewrites with — or `null`
|
|
124
|
+
* when the block is not loadable as a YAML mapping. Also reports the platform
|
|
125
|
+
* string fields whose value is not a string ({@link PlatformStringSplit}).
|
|
126
|
+
*
|
|
127
|
+
* Scalars publish their text (`name`, `description`, `whenToUse` are strings by
|
|
128
|
+
* contract; a number/boolean-shaped value keeps the text the family always
|
|
129
|
+
* published), a flow or block sequence publishes its inline `[a, b]` form
|
|
130
|
+
* (`relatedSkillNames` scans names out of it), and a nested mapping publishes
|
|
131
|
+
* nothing — no consumer in this family reads one, and a lossy string could be
|
|
132
|
+
* picked up by a routing field. A string value is trimmed: YAML's block-scalar
|
|
133
|
+
* chomping appends a newline that the family's single-line routing fields never
|
|
134
|
+
* carried.
|
|
135
|
+
*/
|
|
136
|
+
function strictFrontmatterValues(block) {
|
|
137
|
+
// An empty or comment-only block has no entries and is NOT a parser failure:
|
|
138
|
+
// the catalog's complaint about it is the missing name, which
|
|
139
|
+
// `validateFrontmatter` reports.
|
|
140
|
+
if (block.trim() === '')
|
|
141
|
+
return { values: new Map(), split: [] };
|
|
142
|
+
let loaded;
|
|
143
|
+
try {
|
|
144
|
+
loaded = parseYaml(block);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
if (loaded === null || loaded === undefined)
|
|
150
|
+
return { values: new Map(), split: [] };
|
|
151
|
+
if (typeof loaded !== 'object' || Array.isArray(loaded))
|
|
152
|
+
return null;
|
|
153
|
+
const values = new Map();
|
|
154
|
+
const split = [];
|
|
155
|
+
let metadata;
|
|
156
|
+
for (const [key, value] of Object.entries(loaded)) {
|
|
157
|
+
if (typeof value !== 'string' && PLATFORM_STRING_FIELDS.includes(key)) {
|
|
158
|
+
split.push({ key, kind: value === null || value === undefined ? 'null' : Array.isArray(value) ? 'sequence' : typeof value === 'object' ? 'mapping' : 'scalar' });
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === 'string') {
|
|
161
|
+
values.set(key, value.trim());
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
165
|
+
values.set(key, String(value));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (Array.isArray(value)) {
|
|
169
|
+
values.set(key, `[${value.map(item => String(item)).join(', ')}]`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (key === 'metadata' && isRecord(value)) {
|
|
173
|
+
metadata = value;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { values, split, metadata };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Lenient line scan, used only for a block the strict parser rejects: the
|
|
181
|
+
* family keeps routing a legacy file the platform refuses, and
|
|
182
|
+
* `catalogInvalid` reports the split instead of hiding it. Values are trimmed
|
|
183
|
+
* and unquoted exactly as before.
|
|
184
|
+
*/
|
|
185
|
+
function lenientFrontmatterValues(block, nl) {
|
|
186
|
+
const values = new Map();
|
|
187
|
+
const blockLines = block.split(nl);
|
|
188
|
+
for (let i = 0; i < blockLines.length; i += 1) {
|
|
189
|
+
const line = withoutCr(blockLines[i] ?? '');
|
|
190
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
191
|
+
if (!match)
|
|
192
|
+
continue;
|
|
193
|
+
const [, key, value] = match;
|
|
194
|
+
if (key === undefined || value === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
// V27 G0.3 (core-a-07): a block scalar's VALUE is the more-indented lines
|
|
197
|
+
// that follow its indicator, not the indicator itself. Reading only the
|
|
198
|
+
// header line published the literal ">" as the description — the skill
|
|
199
|
+
// loaded, but its routing information was gone (and the maintenance audit
|
|
200
|
+
// then flagged a file the platform catalog loads happily). Folding (`>`)
|
|
201
|
+
// joins the lines with single spaces and keeps a blank line as a break;
|
|
202
|
+
// literal (`|`) keeps the line structure — the value a strict YAML reader
|
|
203
|
+
// produces for the same block.
|
|
204
|
+
const header = /^([>|])[+-]?\d*$/.exec(value.trim());
|
|
205
|
+
if (header !== null) {
|
|
206
|
+
const fold = header[1] === '>';
|
|
207
|
+
const parts = [];
|
|
208
|
+
let scan = i + 1;
|
|
209
|
+
for (; scan < blockLines.length; scan += 1) {
|
|
210
|
+
const raw = blockLines[scan] ?? '';
|
|
211
|
+
if (raw.trim() === '') {
|
|
212
|
+
parts.push('');
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!/^\s/.test(raw))
|
|
216
|
+
break; // dedented → the next frontmatter key
|
|
217
|
+
parts.push(raw.trim());
|
|
218
|
+
}
|
|
219
|
+
while (parts.length > 0 && parts[parts.length - 1] === '')
|
|
220
|
+
parts.pop();
|
|
221
|
+
let folded = '';
|
|
222
|
+
for (const part of parts) {
|
|
223
|
+
if (part === '') {
|
|
224
|
+
// A blank line ends the paragraph: one break, never a run of them.
|
|
225
|
+
if (folded !== '' && !folded.endsWith('\n'))
|
|
226
|
+
folded += '\n';
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (folded === '' || folded.endsWith('\n'))
|
|
230
|
+
folded += part;
|
|
231
|
+
else
|
|
232
|
+
folded += ` ${part}`;
|
|
233
|
+
}
|
|
234
|
+
values.set(key, (fold ? folded : parts.join('\n')).trim());
|
|
235
|
+
i = scan - 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
values.set(key, value.trim().replace(/^["']|["']$/g, ''));
|
|
239
|
+
}
|
|
240
|
+
return values;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The single read of a SKILL.md frontmatter block: values, body and every
|
|
244
|
+
* strict-catalog signal, computed once. `null` only when the file has no
|
|
245
|
+
* frontmatter block; the body may be empty. {@link parseFrontmatter} and
|
|
246
|
+
* {@link frontmatterCatalogInvalid} are its two projections.
|
|
247
|
+
*
|
|
248
|
+
* @param content - the SKILL.md text.
|
|
249
|
+
* @returns the block read, or `null` when there is no block.
|
|
250
|
+
*/
|
|
251
|
+
function readFrontmatterBlock(content) {
|
|
252
|
+
const found = frontmatterBlock(content);
|
|
253
|
+
if (!found)
|
|
254
|
+
return null;
|
|
255
|
+
const body = found.lines.slice(found.end + 1).join('\n').trim();
|
|
256
|
+
const strict = strictFrontmatterValues(found.block);
|
|
257
|
+
const values = strict?.values ?? lenientFrontmatterValues(found.block, found.nl);
|
|
258
|
+
const frontmatter = {};
|
|
259
|
+
for (const [key, value] of values)
|
|
260
|
+
frontmatter[key] = value;
|
|
261
|
+
return {
|
|
262
|
+
frontmatter,
|
|
263
|
+
body,
|
|
264
|
+
unsafeValues: unsafeFrontmatterEntries(found.block),
|
|
265
|
+
strictFailed: strict === null,
|
|
266
|
+
platformStringSplit: strict?.split ?? [],
|
|
267
|
+
metadata: strict?.metadata,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Parse a SKILL.md: its frontmatter values and body, or `null` when the file
|
|
272
|
+
* has no frontmatter block or no body. Every consumer of frontmatter values
|
|
273
|
+
* goes through here — the write path's validation, `list()`'s published
|
|
274
|
+
* description, `relatedSkillNames` and the audit — so all of them read the same
|
|
275
|
+
* bytes the same way.
|
|
276
|
+
*
|
|
277
|
+
* @param content - the SKILL.md text.
|
|
278
|
+
* @returns the read, or `null` when there is no block or no body.
|
|
279
|
+
*/
|
|
280
|
+
export function parseFrontmatter(content) {
|
|
281
|
+
const read = readFrontmatterBlock(content);
|
|
282
|
+
if (!read || !read.body)
|
|
283
|
+
return null;
|
|
284
|
+
return {
|
|
285
|
+
frontmatter: read.frontmatter,
|
|
286
|
+
body: read.body,
|
|
287
|
+
unsafeValues: read.unsafeValues,
|
|
288
|
+
metadata: read.metadata,
|
|
289
|
+
catalogInvalid: read.strictFailed || read.unsafeValues.length > 0 || read.platformStringSplit.length > 0,
|
|
290
|
+
platformStringSplit: read.platformStringSplit,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Whether this file's frontmatter is valid as written for the strict platform
|
|
295
|
+
* catalog (see `FrontmatterRead.catalogInvalid`). Body-independent (a body-less
|
|
296
|
+
* file is still judged), and derived from the same read as `parseFrontmatter` —
|
|
297
|
+
* so the audit's verdict and the values the family publishes for one file can
|
|
298
|
+
* never disagree (V27 G2.1).
|
|
299
|
+
*
|
|
300
|
+
* @param content - the SKILL.md text.
|
|
301
|
+
* @returns `true` when the strict parser rejects the block or an unquoted value would read as something else.
|
|
302
|
+
*/
|
|
303
|
+
export function frontmatterCatalogInvalid(content) {
|
|
304
|
+
const read = readFrontmatterBlock(content);
|
|
305
|
+
if (read !== null)
|
|
306
|
+
return read.strictFailed || read.unsafeValues.length > 0 || read.platformStringSplit.length > 0;
|
|
307
|
+
// v28 G2.3 (CORE-SK-02): fail closed for the DETECTION failures this guard
|
|
308
|
+
// exists for — a BOM-prefixed fence or mixed line endings make the block
|
|
309
|
+
// undetectable to the byte-exact extractor while a real YAML parser (the
|
|
310
|
+
// platform's) may still read it, so "catalog-valid" here was the one
|
|
311
|
+
// fail-open verdict in this file. Probe: strip the BOM and normalize CRLF,
|
|
312
|
+
// then re-extract; if the block appears, report the split risk. A file with
|
|
313
|
+
// no fence at all, or a genuinely unterminated block, stays "not
|
|
314
|
+
// applicable" — structure health owns that verdict and body-only files
|
|
315
|
+
// must not flood the catalogInvalid channel.
|
|
316
|
+
const probed = frontmatterBlock(content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'));
|
|
317
|
+
return probed !== null;
|
|
318
|
+
}
|
|
319
|
+
/** YAML plain-scalar hazards that make an UNQUOTED frontmatter value
|
|
320
|
+
* unloadable to the platform catalog (strict YAML parser): `: ` (mapping
|
|
321
|
+
* separator), ` #` (comment start), a trailing `:` (a mapping marker),
|
|
322
|
+
* or a leading YAML indicator. The evolution `parseFrontmatter` is
|
|
323
|
+
* deliberately lenient, so violations silently split family-visibility from
|
|
324
|
+
* platform-visibility (0.3.11 inkos-harness case: the description carried
|
|
325
|
+
* "…: " and the catalog dropped the whole skill). Already-quoted values and
|
|
326
|
+
* well-formed flow collections (`[a, b]` / `{a: b}`) are considered safe.
|
|
327
|
+
* 0.3.16 (E-47): null/bool/number-shaped plain scalars are flagged too — they
|
|
328
|
+
* parse as booleans/numbers on the platform while the family keeps the string
|
|
329
|
+
* (a `description: true` split-brain).
|
|
330
|
+
* This rule is only the FAST PATH — the write path re-verifies every rewrite
|
|
331
|
+
* with the real YAML parser (see normalizeFrontmatter), so an incomplete
|
|
332
|
+
* approximation can never corrupt a multiline flow value (P3-4). */
|
|
333
|
+
export function yamlPlainScalarNeedsQuotes(value) {
|
|
334
|
+
if (value.length === 0)
|
|
335
|
+
return false;
|
|
336
|
+
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))))
|
|
337
|
+
return false;
|
|
338
|
+
if (/^\[.*\]$/.test(value) || /^\{.*\}$/.test(value))
|
|
339
|
+
return false;
|
|
340
|
+
// V27 G0.3 (core-a-07): a block scalar header (`>` / `|`, optionally with a
|
|
341
|
+
// chomping/indent modifier) is NOT a plain scalar that needs quotes — it is
|
|
342
|
+
// the legal YAML form for a multi-line value whose CONTINUATION LINES carry
|
|
343
|
+
// the content. Quoting it ("description: >") re-reads as the literal string
|
|
344
|
+
// ">" and, because the continuation lines stay indented underneath, makes the
|
|
345
|
+
// whole rewritten block unparsable: the real-parser verification then failed
|
|
346
|
+
// and every create/update/patch of such a skill was rejected with advice
|
|
347
|
+
// ("wrap the value in double quotes") that would corrupt the value. The
|
|
348
|
+
// indicator is only meaningful in the value position — a leading `>`/`|`
|
|
349
|
+
// inside a longer value (`>x`, `|foo`) still needs quoting below.
|
|
350
|
+
if (/^[>|][+-]?\d*$/.test(value))
|
|
351
|
+
return false;
|
|
352
|
+
if (value.includes(': '))
|
|
353
|
+
return true;
|
|
354
|
+
if (value.includes(' #'))
|
|
355
|
+
return true;
|
|
356
|
+
if (value.endsWith(':'))
|
|
357
|
+
return true;
|
|
358
|
+
// v28 G2.2 (CORE-SK-01): the full YAML 1.2 core scalar set the strict
|
|
359
|
+
// parser coerces to null/bool/number — ints (dec/hex/octal), exponent
|
|
360
|
+
// floats, .inf/.nan — not just plain decimals. A value like `0x1F` used to
|
|
361
|
+
// pass the fast path unquoted, so the platform catalog read a NUMBER while
|
|
362
|
+
// the family published the string `31` (the E-47 split-brain this guard
|
|
363
|
+
// exists for) and no self-heal ever fired.
|
|
364
|
+
// S2.1 (PLAN 2026-09-16): this branch carries the SIGNED infinity forms
|
|
365
|
+
// (`+.inf`/`-.inf`) — under the platform's `yaml` parser (and js-yaml
|
|
366
|
+
// alike, per the pair-compare) they coerce to ±Infinity, so an unquoted
|
|
367
|
+
// `description: +.inf` made the catalog read a float and drop the file while
|
|
368
|
+
// the family listed it. Quoted, both sides read the string.
|
|
369
|
+
// NaN is UNSIGNED in both parsers (review B-P2): `+.nan`/`-.nan` are plain
|
|
370
|
+
// strings, so flagging them quoted a correct value and raised a false
|
|
371
|
+
// catalog-invalid verdict — only bare `.nan` belongs in this table.
|
|
372
|
+
// The 1.1 bool words (yes/no/on/off) deliberately stay OUT of the table:
|
|
373
|
+
// under YAML 1.2 core — and under js-yaml 4 too, per the对拍 — they are
|
|
374
|
+
// plain strings on both sides, so quoting them would be redundant churn.
|
|
375
|
+
// The null/true/false/~ entries remain for parser-fallback safety: any
|
|
376
|
+
// YAML 1.1 full parser reintroduced here WOULD coerce those, and the table
|
|
377
|
+
// keeps the write path honest if that ever happens.
|
|
378
|
+
if (/^(?:null|true|false|~)$/i.test(value))
|
|
379
|
+
return true;
|
|
380
|
+
if (/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/.test(value))
|
|
381
|
+
return true;
|
|
382
|
+
if (/^0x[0-9a-f]+$/i.test(value))
|
|
383
|
+
return true;
|
|
384
|
+
if (/^0o[0-7]+$/.test(value))
|
|
385
|
+
return true;
|
|
386
|
+
if (/^(?:[-+]?\.inf|\.nan)$/i.test(value))
|
|
387
|
+
return true;
|
|
388
|
+
if (/^[-?:,[\]{}#&*!|>'\"%@`\s]/.test(value))
|
|
389
|
+
return true;
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Normalize a SKILL.md frontmatter block into catalog-loadable YAML: values
|
|
394
|
+
* that YAML forbids unquoted get quotes — double quotes normally, single
|
|
395
|
+
* quotes (with `''` doubling) when the value contains `"` or `\` (both legal
|
|
396
|
+
* unescaped inside single-quoted YAML). Idempotent; only single-line
|
|
397
|
+
* `key: value` entries are touched; body text is never modified; line-ending
|
|
398
|
+
* style is preserved. **Every rewrite is re-verified with the real YAML
|
|
399
|
+
* parser** (`yaml` — the package the platform's skill-filesystem catalog parses
|
|
400
|
+
* with, YAML 1.2 core schema; PLAN S2.1, 2026-09-16 — the former "js-yaml, the
|
|
401
|
+
* same parser" claim was false: js-yaml speaks YAML 1.1 full and diverged on
|
|
402
|
+
* dates, timestamps and 1.1 int forms): if the
|
|
403
|
+
* rewritten block no longer parses, or a rewritten value's parsed content
|
|
404
|
+
* differs from the original, the rewrite is rolled back and reported in
|
|
405
|
+
* `issues` (fail-loud, never a silent value corruption — P3-4).
|
|
406
|
+
*
|
|
407
|
+
* V10-02 (P2-3): the rewrite decision is PER LINE — each entry parses its own
|
|
408
|
+
* value, so a duplicated key can never route one entry's unsafe value into a
|
|
409
|
+
* different line's rewrite (the old key→Map lookup rewrote the FIRST (safe)
|
|
410
|
+
* line with the SECOND line's quoted value, and the last-wins YAML reader
|
|
411
|
+
* masked the damage). A duplicated key is itself invalid input and is
|
|
412
|
+
* reported in `issues` (the write path refuses) instead of being rewritten.
|
|
413
|
+
*/
|
|
414
|
+
export function normalizeFrontmatter(content) {
|
|
415
|
+
const block = frontmatterBlock(content);
|
|
416
|
+
if (!block)
|
|
417
|
+
return { content, changed: false, fields: [], issues: [] };
|
|
418
|
+
// S1.1: `nl` is no longer needed here — every line keeps its own CR and the
|
|
419
|
+
// rebuild joins on LF, which reproduces the original bytes.
|
|
420
|
+
const { lines, end } = block;
|
|
421
|
+
const fields = [];
|
|
422
|
+
const issues = [];
|
|
423
|
+
// Detection shares the predicate with the audit side
|
|
424
|
+
// (frontmatterCatalogInvalid): normalize and catalog-invalid detection
|
|
425
|
+
// can never disagree.
|
|
426
|
+
const seen = new Set();
|
|
427
|
+
const originalValues = new Map();
|
|
428
|
+
let changed = false;
|
|
429
|
+
for (let i = 1; i < end; i++) {
|
|
430
|
+
const raw = lines[i];
|
|
431
|
+
if (raw === undefined)
|
|
432
|
+
continue;
|
|
433
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(withoutCr(raw));
|
|
434
|
+
if (!match)
|
|
435
|
+
continue;
|
|
436
|
+
const key = match[1];
|
|
437
|
+
if (key === undefined)
|
|
438
|
+
continue;
|
|
439
|
+
// V10-02 (P2-3): parse THIS line's own value (quotes included) — never a
|
|
440
|
+
// same-key value from another line.
|
|
441
|
+
const value = (match[2] ?? '').trim();
|
|
442
|
+
if (seen.has(key)) {
|
|
443
|
+
issues.push(`${key}: duplicate frontmatter key — remove the repeated entry and retry`);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
seen.add(key);
|
|
447
|
+
if (!yamlPlainScalarNeedsQuotes(value))
|
|
448
|
+
continue;
|
|
449
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(value)) {
|
|
450
|
+
issues.push(`${key}: value contains control characters — clean them manually`);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
// 0.3.11 fix: single-quote fallback so a `"`/`\` value stays fixable
|
|
454
|
+
// through the write path (no catch-22 on legacy descriptions).
|
|
455
|
+
const quoted = value.includes('"') || value.includes('\\')
|
|
456
|
+
? `'${value.replace(/'/g, "''")}'`
|
|
457
|
+
: `"${value}"`;
|
|
458
|
+
// S1.1: keep the line's own CR — the block is split on LF now, so the line
|
|
459
|
+
// ending lives in the string itself (join('\n') restores the original bytes).
|
|
460
|
+
lines[i] = `${key}: ${quoted}${raw.endsWith('\r') ? '\r' : ''}`;
|
|
461
|
+
originalValues.set(key, value);
|
|
462
|
+
fields.push(key);
|
|
463
|
+
changed = true;
|
|
464
|
+
}
|
|
465
|
+
// V10-02 (P2-3): any issue (duplicate key, control characters) refuses the
|
|
466
|
+
// whole rewrite — the write path rejects instead of shipping a partial fix.
|
|
467
|
+
if (issues.length > 0)
|
|
468
|
+
return { content, changed: false, fields: [], issues };
|
|
469
|
+
if (!changed)
|
|
470
|
+
return { content, changed: false, fields: [], issues };
|
|
471
|
+
// 0.3.14: verify the rewritten block with the real parser — the fast-path
|
|
472
|
+
// rule can mis-detect a multiline flow collection (`[a,` + continuation)
|
|
473
|
+
// or any shape the plain-scalar approximation does not know. A failure must
|
|
474
|
+
// roll back, never ship a value mutation.
|
|
475
|
+
const rewrittenBlock = lines.slice(1, end).join('\n');
|
|
476
|
+
try {
|
|
477
|
+
const parsed = parseYaml(rewrittenBlock);
|
|
478
|
+
for (const key of fields) {
|
|
479
|
+
if (String(parsed[key]) !== originalValues.get(key)) {
|
|
480
|
+
throw new Error(`rewritten value for ${key} differs from the original`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return { content: lines.join('\n'), changed: true, fields, issues };
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
487
|
+
return {
|
|
488
|
+
content,
|
|
489
|
+
changed: false,
|
|
490
|
+
fields: [],
|
|
491
|
+
issues: [`frontmatter rewrite verification failed (${reason}) — quoting skipped; wrap this value manually`],
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Skill names referenced by a SKILL.md's `related_skills` frontmatter
|
|
497
|
+
* (B-line G3, rc.44): the single parsing source for the quality references
|
|
498
|
+
* factor and the learning-graph edges. The DSH frontmatter parser keeps the
|
|
499
|
+
* YAML value as a string (`"[a, b]"`), so names are scanned out of it; each
|
|
500
|
+
* must satisfy the skill-name shape and the referencing skill itself is
|
|
501
|
+
* excluded. Pure and deduplicated.
|
|
502
|
+
*/
|
|
503
|
+
export function relatedSkillNames(content, exclude) {
|
|
504
|
+
const parsed = parseFrontmatter(content);
|
|
505
|
+
if (!parsed)
|
|
506
|
+
return [];
|
|
507
|
+
// P0′ (2026-10-14): the real corpus writes the NESTED form —
|
|
508
|
+
// `metadata: hermes: related_skills: [...]` — and the flat top-level key is
|
|
509
|
+
// empty on every live file (实测 52 份:顶层 0 / 嵌套 33). The flat key stays
|
|
510
|
+
// supported for legacy blocks; the nested array is folded into the same
|
|
511
|
+
// delimited-name scan below, so the F-321 name-shape guard still applies.
|
|
512
|
+
const nestedHost = parsed.metadata?.['hermes'];
|
|
513
|
+
const nestedRaw = isRecord(nestedHost) ? nestedHost['related_skills'] : undefined;
|
|
514
|
+
const flat = parsed.frontmatter['related_skills'];
|
|
515
|
+
const raw = typeof flat === 'string'
|
|
516
|
+
? flat
|
|
517
|
+
: Array.isArray(nestedRaw)
|
|
518
|
+
? `[${nestedRaw.map(item => String(item)).join(', ')}]`
|
|
519
|
+
: undefined;
|
|
520
|
+
if (typeof raw !== 'string')
|
|
521
|
+
return [];
|
|
522
|
+
const names = new Set();
|
|
523
|
+
// F-321 (0.3.25): the old bare `[a-z0-9][a-z0-9-]*` scan matched an inner
|
|
524
|
+
// fragment of a CamelCase word — `MySkill` produced the junk token `y` (and
|
|
525
|
+
// would match `kill` in `Skill`-suffixed names), which then fed the quality
|
|
526
|
+
// references factor and learning-graph edges. A name token is only a real
|
|
527
|
+
// reference when it is delimited (no letter/digit/hyphen/underscore on
|
|
528
|
+
// either side), so a camel/underscored word is skipped wholesale instead of
|
|
529
|
+
// leaking fragments.
|
|
530
|
+
for (const match of Array.from(raw.matchAll(/(?<![A-Za-z0-9_-])[a-z0-9][a-z0-9-]*(?![A-Za-z0-9_-])/g))) {
|
|
531
|
+
const target = match[0];
|
|
532
|
+
if (target && SKILL_NAME_RE.test(target) && target !== exclude)
|
|
533
|
+
names.add(target);
|
|
534
|
+
}
|
|
535
|
+
return [...names];
|
|
536
|
+
}
|
|
537
|
+
/** S1.2 (v37 P2-1): the content limit applies to the bytes that LAND ON DISK.
|
|
538
|
+
* Every write normalizes with `trimEnd() + '\n'`, so judging the raw argument let
|
|
539
|
+
* a 100_000-character body with no trailing newline land as 100_001 bytes — and
|
|
540
|
+
* every later patch/update of that skill was then refused, which made it
|
|
541
|
+
* unmaintainable through `skill_manage` with no repair path at all. */
|
|
542
|
+
/** The bytes that land on disk for a SKILL.md write — the ONE accounting basis
|
|
543
|
+
* shared by the content limit and the cost estimate (design §5.2). */
|
|
544
|
+
export function skillMdOnDisk(content) {
|
|
545
|
+
return content.trimEnd() + '\n';
|
|
546
|
+
}
|
|
547
|
+
/** Whether `content` would exceed `limit` once written. */
|
|
548
|
+
export function exceedsContentLimit(content, limit) {
|
|
549
|
+
return skillMdOnDisk(content).length > limit;
|
|
550
|
+
}
|
|
551
|
+
/** S1.2: the repair path — a write that makes an already-over-limit file smaller.
|
|
552
|
+
* Only a NET SHRINK is exempt; an equal or larger write stays refused. */
|
|
553
|
+
export function shrinksOverLimit(next, current, limit) {
|
|
554
|
+
if (current === null || current === undefined || !exceedsContentLimit(current, limit))
|
|
555
|
+
return false;
|
|
556
|
+
return skillMdOnDisk(next).length < skillMdOnDisk(current).length;
|
|
557
|
+
}
|
|
558
|
+
export function validateFrontmatter(content, expectedName, limits = DEFAULT_SKILL_LIMITS,
|
|
559
|
+
/** On-disk bytes, so a NET SHRINK of an over-limit file is allowed (S1.2). */
|
|
560
|
+
current) {
|
|
561
|
+
const parsed = parseFrontmatter(content);
|
|
562
|
+
// V27 G2.1: name the exact rule (the one the upstream catalog applies) —
|
|
563
|
+
// "start and end with YAML frontmatter" left a ` --- ` fence, or a block with
|
|
564
|
+
// no body, to be discovered by trial and error.
|
|
565
|
+
if (!parsed)
|
|
566
|
+
return 'SKILL.md 必须以 `---` 行开头,用另一个完全相同的 `---` 行结束 frontmatter(只容忍结尾的 `\\r`),并在其下包含正文。';
|
|
567
|
+
// P2-9 (v37): a SEQUENCE/MAPPING under a platform string field is refused —
|
|
568
|
+
// the catalog drops the whole file while the family publishes synthesized
|
|
569
|
+
// text. Number/boolean stay repairable (E-47 auto-quotes them).
|
|
570
|
+
const unreadable = parsed.platformStringSplit.filter(entry => entry.kind === 'sequence' || entry.kind === 'mapping');
|
|
571
|
+
if (unreadable.length > 0) {
|
|
572
|
+
const named = unreadable.map(entry => `"${entry.key}" (a YAML ${entry.kind})`).join(', ');
|
|
573
|
+
return `Frontmatter 字段 ${named} 必须是字符串:平台技能目录会把这种字段当作缺失,并忽略整个文件。` +
|
|
574
|
+
'请写成纯文本,或用双引号包住取值以保持字面内容。';
|
|
575
|
+
}
|
|
576
|
+
if (!parsed.frontmatter.name)
|
|
577
|
+
return 'Frontmatter 必须包含 name 字段。';
|
|
578
|
+
// C-12 (v10 audit): single-source the name shape — the inline copy of
|
|
579
|
+
// SKILL_NAME_RE could silently drift from constants.ts.
|
|
580
|
+
if (!SKILL_NAME_RE.test(parsed.frontmatter.name))
|
|
581
|
+
return `技能名「${parsed.frontmatter.name}」不合法:请只用小写字母、数字和连字符。`;
|
|
582
|
+
if (parsed.frontmatter.name.length > limits.maxNameLength)
|
|
583
|
+
return `技能名超过 ${limits.maxNameLength} 个字符。`;
|
|
584
|
+
if (expectedName && parsed.frontmatter.name !== expectedName)
|
|
585
|
+
return `Frontmatter 里的 name「${parsed.frontmatter.name}」与目标技能「${expectedName}」不一致。`;
|
|
586
|
+
if (!parsed.frontmatter.description)
|
|
587
|
+
return 'Frontmatter 必须包含 description 字段。';
|
|
588
|
+
const description = parsed.frontmatter.description;
|
|
589
|
+
if (description.length > limits.maxDescriptionLength)
|
|
590
|
+
return `description 超过 ${limits.maxDescriptionLength} 个字符。`;
|
|
591
|
+
// S1.2: the limit is judged on the bytes that will land on disk, and a NET
|
|
592
|
+
// SHRINK of an already-over-limit file is allowed (the repair path).
|
|
593
|
+
if (exceedsContentLimit(content, limits.maxSkillContentChars) && !shrinksOverLimit(content, current, limits.maxSkillContentChars)) {
|
|
594
|
+
return `SKILL.md 内容超过 ${limits.maxSkillContentChars} 个字符。${CONTENT_SPLIT_HINT}`;
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Advisory authoring feedback (P0): evaluate frontmatter against the
|
|
600
|
+
* authoring bar WITHOUT changing platform validation semantics. The bar is
|
|
601
|
+
* the quality target, `validateFrontmatter`'s limits are the compatibility
|
|
602
|
+
* floor, and this bridge layer tells the model when its text would be
|
|
603
|
+
* truncated or route-poor instead of silently shipping it.
|
|
604
|
+
*/
|
|
605
|
+
export function authoringFeedback(frontmatter) {
|
|
606
|
+
const description = frontmatter.description ?? '';
|
|
607
|
+
const over60 = description.length > AUTHORING_DESCRIPTION_BAR;
|
|
608
|
+
const hasColon = description.includes(':');
|
|
609
|
+
const lines = [];
|
|
610
|
+
lines.push(over60
|
|
611
|
+
? `描述为 ${description.length}/60 个字符 —— 超过 60 字符的作者规范上限(Hermes 标准;技能目录会按平台配置的上限截断)。`
|
|
612
|
+
: `描述为 ${description.length}/60 个字符 —— 在作者规范上限之内。`);
|
|
613
|
+
if (hasColon)
|
|
614
|
+
lines.push('描述里含冒号 —— 请用双引号把整个取值包起来。');
|
|
615
|
+
return { descriptionChars: description.length, over60, hasColon, lines };
|
|
616
|
+
}
|
|
617
|
+
/** A1-15 (v18) / P2-2 (v19): the io layer marks an error `committed: true` when
|
|
618
|
+
* the rename landed and only the directory fsync failed. Every single-file
|
|
619
|
+
* writer must treat that as "written, durability unconfirmed" — never as a
|
|
620
|
+
* plain failure (which a caller would retry, or a two-phase caller roll back).
|
|
621
|
+
* v28 G2.1 (EVO-IO-05): this is a delegation to the seam's own
|
|
622
|
+
* `isCommittedWarning` — the marker predicate has exactly one definition. */
|
|
623
|
+
//# sourceMappingURL=frontmatter.js.map
|