@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.
Files changed (107) hide show
  1. package/README.md +16 -1
  2. package/lib/composition/index.js +3 -0
  3. package/lib/composition-D8Y3-ROd.js +604 -0
  4. package/lib/ensure-MEYMj9jn.js +222 -0
  5. package/lib/errors-Br2-uUSK.js +57 -0
  6. package/lib/http-routes-BP-S3KV-.js +149 -0
  7. package/lib/http-routes.js +2 -0
  8. package/lib/index.js +66 -850
  9. package/lib/restrictor.js +37 -0
  10. package/lib/subagent/index.js +186 -0
  11. package/lib/types/citations.js +192 -0
  12. package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
  13. package/lib/types/composition/composer.js +681 -0
  14. package/lib/types/composition/ensure.d.ts +10 -0
  15. package/lib/types/composition/ensure.js +95 -0
  16. package/lib/types/composition/index.d.ts +5 -0
  17. package/lib/types/composition/index.js +7 -0
  18. package/lib/types/composition/preset-ids.d.ts +17 -0
  19. package/lib/types/composition/preset-ids.js +21 -0
  20. package/lib/types/composition/restricted-preset.d.ts +36 -0
  21. package/lib/types/composition/restricted-preset.js +42 -0
  22. package/lib/types/constants.js +223 -0
  23. package/lib/types/cost.js +67 -0
  24. package/lib/types/curator.js +346 -0
  25. package/lib/types/diagnostics.d.ts +26 -0
  26. package/lib/types/diagnostics.js +23 -0
  27. package/lib/types/document-body.js +91 -0
  28. package/lib/types/drift-signals.js +319 -0
  29. package/lib/types/env.js +25 -0
  30. package/lib/types/errors.d.ts +4 -1
  31. package/lib/types/errors.js +59 -0
  32. package/lib/types/events.js +16 -0
  33. package/lib/types/evidence.js +65 -0
  34. package/lib/types/evolution-events.js +479 -0
  35. package/lib/types/fold-untrusted.js +44 -0
  36. package/lib/types/frontmatter.js +623 -0
  37. package/lib/types/fuzzy-match.js +135 -0
  38. package/lib/types/gates.js +51 -0
  39. package/lib/types/guards.js +27 -0
  40. package/lib/types/health-baseline.js +63 -0
  41. package/lib/types/http-routes.d.ts +67 -35
  42. package/lib/types/http-routes.js +135 -0
  43. package/lib/types/index.d.ts +7 -2
  44. package/lib/types/index.js +101 -0
  45. package/lib/types/instance-scope.js +69 -0
  46. package/lib/types/io.js +675 -0
  47. package/lib/types/learn-prompt.js +42 -0
  48. package/lib/types/limits.js +63 -0
  49. package/lib/types/live.js +107 -0
  50. package/lib/types/long-runs.js +96 -0
  51. package/lib/types/memory-store.d.ts +0 -16
  52. package/lib/types/memory-store.js +606 -0
  53. package/lib/types/mutations.js +95 -0
  54. package/lib/types/namespace-migration.js +156 -0
  55. package/lib/types/numeric.js +47 -0
  56. package/lib/types/opt-in.js +101 -0
  57. package/lib/types/package-identity.js +47 -0
  58. package/lib/types/params.js +328 -0
  59. package/lib/types/payload.js +36 -0
  60. package/lib/types/platform-line.generated.d.ts +10 -0
  61. package/lib/types/platform-line.generated.js +12 -0
  62. package/lib/types/platform-services.d.ts +2 -2
  63. package/lib/types/platform-services.js +52 -0
  64. package/lib/types/probe.js +122 -0
  65. package/lib/types/prompts.js +409 -0
  66. package/lib/types/quality.js +308 -0
  67. package/lib/types/reachability.js +122 -0
  68. package/lib/types/redact.js +257 -0
  69. package/lib/types/reference-rewrite.js +144 -0
  70. package/lib/types/reports.js +110 -0
  71. package/lib/types/restrictor.d.ts +11 -0
  72. package/lib/types/restrictor.js +48 -0
  73. package/lib/types/review-channel.js +59 -0
  74. package/lib/types/run-registry.js +345 -0
  75. package/lib/types/scope.js +7 -0
  76. package/lib/types/serial.js +17 -0
  77. package/lib/types/session-fold.d.ts +15 -0
  78. package/lib/types/session-fold.js +28 -0
  79. package/lib/types/session-projection.js +295 -0
  80. package/lib/types/signals.js +209 -0
  81. package/lib/types/skill-discovery.js +66 -0
  82. package/lib/types/skill-health.js +134 -0
  83. package/lib/types/skill-history.js +745 -0
  84. package/lib/types/skill-lint.js +112 -0
  85. package/lib/types/skill-reads.js +67 -0
  86. package/lib/types/skill-row-cells.js +123 -0
  87. package/lib/types/skill-store.js +3528 -0
  88. package/lib/types/state-store.js +60 -0
  89. package/lib/types/store-ledger.js +117 -0
  90. package/lib/types/subagent/channel.d.ts +4 -0
  91. package/lib/types/subagent/channel.js +131 -0
  92. package/lib/types/subagent/index.d.ts +4 -0
  93. package/lib/types/subagent/index.js +5 -0
  94. package/lib/types/subagent/readiness.d.ts +6 -0
  95. package/lib/types/subagent/readiness.js +35 -0
  96. package/lib/types/subagent/types.d.ts +58 -0
  97. package/lib/types/subagent/types.js +2 -0
  98. package/lib/types/threats.js +338 -0
  99. package/lib/types/tool-dispatch.d.ts +5 -0
  100. package/lib/types/tool-dispatch.js +535 -0
  101. package/lib/types/tree-write.js +40 -0
  102. package/lib/types/unit-members.js +121 -0
  103. package/lib/types/usage.js +474 -0
  104. package/lib/types/write-authority.js +23 -0
  105. package/lib/types/write-gates.js +344 -0
  106. package/lib/types/write-inventory.js +158 -0
  107. 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