@maci0/dsh-caveman 0.16.2

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 (47) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +192 -0
  3. package/cordis.patch.yml +13 -0
  4. package/icon.svg +6 -0
  5. package/lib/client.js +488 -0
  6. package/lib/compress-detect.js +98 -0
  7. package/lib/compress-files.js +155 -0
  8. package/lib/compress-pipeline.js +109 -0
  9. package/lib/compress-rules.js +308 -0
  10. package/lib/compress-validate.js +227 -0
  11. package/lib/frontmatter.js +347 -0
  12. package/lib/host.js +15 -0
  13. package/lib/index.js +616 -0
  14. package/lib/modes.js +126 -0
  15. package/lib/skills.js +177 -0
  16. package/lib/types/compress-detect.d.ts +18 -0
  17. package/lib/types/compress-files.d.ts +76 -0
  18. package/lib/types/compress-pipeline.d.ts +32 -0
  19. package/lib/types/compress-rules.d.ts +65 -0
  20. package/lib/types/compress-validate.d.ts +36 -0
  21. package/lib/types/frontmatter.d.ts +57 -0
  22. package/lib/types/host.d.ts +201 -0
  23. package/lib/types/index.d.ts +87 -0
  24. package/lib/types/modes.d.ts +102 -0
  25. package/lib/types/skills.d.ts +56 -0
  26. package/locale/en.json +6 -0
  27. package/locale/zh.json +6 -0
  28. package/package.json +112 -0
  29. package/scripts/sync-upstream.mjs +158 -0
  30. package/skills/cavecrew/SKILL.md +91 -0
  31. package/skills/cavecrew/cavecrew-builder.md +46 -0
  32. package/skills/cavecrew/cavecrew-investigator.md +56 -0
  33. package/skills/cavecrew/cavecrew-reviewer.md +47 -0
  34. package/skills/caveman/SKILL.md +103 -0
  35. package/skills/caveman-commit/SKILL.md +63 -0
  36. package/skills/caveman-compress/SKILL.md +105 -0
  37. package/skills/caveman-explore/SKILL.md +42 -0
  38. package/skills/caveman-help/SKILL.md +68 -0
  39. package/skills/caveman-review/SKILL.md +53 -0
  40. package/skills/caveman-stats/SKILL.md +30 -0
  41. package/skills/investigate-first/SKILL.md +16 -0
  42. package/skills/lean-build/SKILL.md +18 -0
  43. package/skills/migration/SKILL.md +17 -0
  44. package/skills/safe-refactor/SKILL.md +16 -0
  45. package/skills/surgical-patch/SKILL.md +16 -0
  46. package/skills/verify-and-stop/SKILL.md +16 -0
  47. package/sync.manifest.json +25 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Structural validation of a compressed file against its original.
3
+ *
4
+ * TypeScript port of `skills/caveman-compress/scripts/validate.py` (MIT,
5
+ * © JuliusBrussee). Same extractors, same six validators, same fail-closed
6
+ * posture: headings/code/URLs/paths/inline-code must survive byte-identical,
7
+ * bullets only warn on drift. The Python original is dropped.
8
+ *
9
+ * @module dsh-caveman/compress-validate
10
+ */
11
+ import { maskCodeBlocks } from './compress-rules.js';
12
+ const URL_REGEX = /https?:\/\/[^\s)]+/g;
13
+ const FENCE_OPEN_REGEX = /^(\s{0,3})(`{3,}|~{3,})(.*)$/;
14
+ const FENCE_MARKER_LINE_REGEX = /^\s*(?:`{3,}|~{3,})[^`~]*$/;
15
+ const HEADING_REGEX = /^(#{1,6})\s+(.*)/gm;
16
+ const BULLET_REGEX = /^\s*[-*+]\s+/gm;
17
+ /**
18
+ * Path-shaped runs: an absolute/relative prefix plus its body, or a bare
19
+ * `dir/file`.
20
+ *
21
+ * The bare-path branch carries a `(?<![\w.-])` boundary: a mid-run start can
22
+ * never produce a match the run's first character would not already produce
23
+ * (that match is a superset, and leftmost-first wins), so the lookbehind
24
+ * rejects the mid-word starts the engine would otherwise walk suffix by
25
+ * suffix. Match-identical to the greedy form HEAD ships and to the lazy form
26
+ * the previous pass introduced, on 388 inputs: the bench corpus at six sizes,
27
+ * 381 markdown files under the plugin tree and the harness checkout, and one
28
+ * adversarial set of slash-free long words, drive prefixes and mixed
29
+ * separators.
30
+ */
31
+ const PATH_REGEX = /(?:\.\/|\.\.\/|\/|[A-Za-z]:\\)[\w\-\/\\.]+|(?<![\w.-])[\w\-.]+[\/\\][\w\-\/\\.]+/g;
32
+ const DEFINITE_PATH_REGEX = /^(?:\.\/|\.\.\/|\/|[A-Za-z]:\\)|[^\/\\]*\.[A-Za-z0-9]{1,8}$/;
33
+ function invalid(errors, warnings) {
34
+ return { isValid: errors.length === 0, errors, warnings };
35
+ }
36
+ export function extractHeadings(text) {
37
+ const out = [];
38
+ for (const match of text.matchAll(HEADING_REGEX)) {
39
+ out.push([match[1] ?? '', (match[2] ?? '').trim()]);
40
+ }
41
+ return out;
42
+ }
43
+ function isFenceClose(line, fenceChar, fenceLen) {
44
+ const close = FENCE_OPEN_REGEX.exec(line);
45
+ const run = close?.[2];
46
+ if (run === undefined)
47
+ return false;
48
+ return run[0] === fenceChar && run.length >= fenceLen && (close?.[3] ?? '').trim() === '';
49
+ }
50
+ /**
51
+ * Every fenced and indented code block, in document order.
52
+ *
53
+ * The masker already owns the definition of "code block" for the rewriter; the
54
+ * validator reuses it so both agree on what must survive byte-identical.
55
+ * @param text - markdown body.
56
+ * @returns the block texts.
57
+ */
58
+ export function extractCodeBlocks(text) {
59
+ return maskCodeBlocks(text).blocks;
60
+ }
61
+ export function extractUrls(text) {
62
+ return new Set(text.match(URL_REGEX) ?? []);
63
+ }
64
+ export function extractPaths(text) {
65
+ return new Set(text.match(PATH_REGEX) ?? []);
66
+ }
67
+ function countBullets(text) {
68
+ return text.match(BULLET_REGEX)?.length ?? 0;
69
+ }
70
+ export function extractInlineCodes(text) {
71
+ // Blank the fenced/marker lines in place and join once: fence bodies never
72
+ // contribute inline spans, and the join keeps a backtick span that straddles
73
+ // a blanked block byte-identical to the old two-pass form. One walk does both
74
+ // blankings: it opens a span exactly where a forward fence scan would and
75
+ // skips to the closer, then applies the marker test outside a span, instead
76
+ // of collecting span tuples and walking the lines twice.
77
+ const lines = text.split('\n');
78
+ let index = 0;
79
+ while (index < lines.length) {
80
+ const line = lines[index] ?? '';
81
+ const open = FENCE_OPEN_REGEX.exec(line);
82
+ if (open?.[2] !== undefined) {
83
+ const fenceChar = open[2][0] ?? '';
84
+ const fenceLen = open[2].length;
85
+ lines[index] = '';
86
+ index += 1;
87
+ while (index < lines.length) {
88
+ const inner = lines[index] ?? '';
89
+ const closed = isFenceClose(inner, fenceChar, fenceLen);
90
+ lines[index] = '';
91
+ index += 1;
92
+ if (closed)
93
+ break;
94
+ }
95
+ continue;
96
+ }
97
+ if (line !== '' && FENCE_MARKER_LINE_REGEX.test(line))
98
+ lines[index] = '';
99
+ index += 1;
100
+ }
101
+ return [...lines.join('\n').matchAll(/`([^`]+)`/g)].map((match) => match[1] ?? '');
102
+ }
103
+ function difference(left, right) {
104
+ return new Set([...left].filter((item) => !right.has(item)));
105
+ }
106
+ /**
107
+ * Element-wise equality of two string lists. Used where the result only needs
108
+ * "same sequence": `JSON.stringify` would be equivalent but materializes two
109
+ * whole serialized documents (thousands of code blocks on a large file) to
110
+ * answer a question a length check and a loop answer with no allocation.
111
+ * @param left - first list.
112
+ * @param right - second list.
113
+ * @returns true when both lists hold the same strings in the same order.
114
+ */
115
+ function sameStrings(left, right) {
116
+ if (left.length !== right.length)
117
+ return false;
118
+ for (let index = 0; index < left.length; index += 1) {
119
+ if (left[index] !== right[index])
120
+ return false;
121
+ }
122
+ return true;
123
+ }
124
+ /**
125
+ * Element-wise equality of two heading lists, level and title.
126
+ * @param left - first list.
127
+ * @param right - second list.
128
+ * @returns true when both hold the same pairs in the same order.
129
+ */
130
+ function sameHeadings(left, right) {
131
+ if (left.length !== right.length)
132
+ return false;
133
+ for (let index = 0; index < left.length; index += 1) {
134
+ const pair = left[index];
135
+ const other = right[index];
136
+ if (pair === undefined || other === undefined || pair[0] !== other[0] || pair[1] !== other[1])
137
+ return false;
138
+ }
139
+ return true;
140
+ }
141
+ function renderSpans(spans) {
142
+ const rendered = [...spans]
143
+ .map((span) => span.replaceAll('\n', '\\n'))
144
+ .map((flat) => (flat.length > 60 ? `${flat.slice(0, 60)}…` : flat))
145
+ .map((flat) => JSON.stringify(flat))
146
+ .sort();
147
+ return `{${rendered.join(', ')}}`;
148
+ }
149
+ /**
150
+ * Validate a compressed candidate against its original.
151
+ * @param original - original file text.
152
+ * @param compressed - compressed candidate text.
153
+ * @returns errors (fail-closed) and warnings (drift notes).
154
+ */
155
+ export function validate(original, compressed) {
156
+ const errors = [];
157
+ const warnings = [];
158
+ const headingsOriginal = extractHeadings(original);
159
+ const headingsCompressed = extractHeadings(compressed);
160
+ if (headingsOriginal.length !== headingsCompressed.length) {
161
+ errors.push(`Heading count mismatch: ${headingsOriginal.length} vs ${headingsCompressed.length}`);
162
+ }
163
+ else {
164
+ const titlesOriginal = headingsOriginal.map(([, title]) => title);
165
+ const titlesCompressed = headingsCompressed.map(([, title]) => title);
166
+ if (!sameStrings(titlesOriginal, titlesCompressed)) {
167
+ const lost = titlesOriginal.filter((title) => !titlesCompressed.includes(title));
168
+ const added = titlesCompressed.filter((title) => !titlesOriginal.includes(title));
169
+ errors.push(`Heading text/order changed: lost=${JSON.stringify(lost)}, added=${JSON.stringify(added)}`);
170
+ }
171
+ else if (!sameHeadings(headingsOriginal, headingsCompressed)) {
172
+ warnings.push('Heading levels changed');
173
+ }
174
+ }
175
+ const codeOriginal = extractCodeBlocks(original);
176
+ const codeCompressed = extractCodeBlocks(compressed);
177
+ if (!sameStrings(codeOriginal, codeCompressed)) {
178
+ errors.push('Code blocks not preserved exactly');
179
+ }
180
+ const urlsOriginal = extractUrls(original);
181
+ const urlsCompressed = extractUrls(compressed);
182
+ const urlsLost = difference(urlsOriginal, urlsCompressed);
183
+ const urlsAdded = difference(urlsCompressed, urlsOriginal);
184
+ if (urlsLost.size > 0 || urlsAdded.size > 0) {
185
+ errors.push(`URL mismatch: lost=${JSON.stringify([...urlsLost])}, added=${JSON.stringify([...urlsAdded])}`);
186
+ }
187
+ const pathsOriginal = extractPaths(original);
188
+ const pathsCompressed = extractPaths(compressed);
189
+ const pathsLost = difference(pathsOriginal, pathsCompressed);
190
+ const pathsAdded = difference(pathsCompressed, pathsOriginal);
191
+ const definite = [...pathsLost].filter((path) => DEFINITE_PATH_REGEX.test(path));
192
+ if (definite.length > 0)
193
+ errors.push(`File paths lost: ${JSON.stringify(definite.sort())}`);
194
+ const indefinite = [...pathsLost].filter((path) => !DEFINITE_PATH_REGEX.test(path));
195
+ if (indefinite.length > 0 || pathsAdded.size > 0) {
196
+ warnings.push(`Path mismatch: lost=${JSON.stringify([...pathsLost].sort())}, added=${JSON.stringify([...pathsAdded].sort())}`);
197
+ }
198
+ const bulletsOriginal = countBullets(original);
199
+ const bulletsCompressed = countBullets(compressed);
200
+ if (bulletsOriginal > 0 && Math.abs(bulletsOriginal - bulletsCompressed) / bulletsOriginal > 0.15) {
201
+ warnings.push(`Bullet count changed too much: ${bulletsOriginal} -> ${bulletsCompressed}`);
202
+ }
203
+ const codesOriginal = extractInlineCodes(original);
204
+ const codesCompressed = extractInlineCodes(compressed);
205
+ const countOriginal = new Map();
206
+ for (const span of codesOriginal)
207
+ countOriginal.set(span, (countOriginal.get(span) ?? 0) + 1);
208
+ const countCompressed = new Map();
209
+ for (const span of codesCompressed)
210
+ countCompressed.set(span, (countCompressed.get(span) ?? 0) + 1);
211
+ const lostSpans = new Set();
212
+ const addedSpans = new Set();
213
+ for (const [span, count] of countOriginal) {
214
+ const have = countCompressed.get(span) ?? 0;
215
+ if (have < count)
216
+ lostSpans.add(have === 0 ? span : `${span} (lost ${count - have} of ${count} occurrences)`);
217
+ }
218
+ for (const span of countCompressed.keys()) {
219
+ if (!countOriginal.has(span))
220
+ addedSpans.add(span);
221
+ }
222
+ if (lostSpans.size > 0)
223
+ errors.push(`Inline code lost: ${renderSpans(lostSpans)}`);
224
+ if (addedSpans.size > 0)
225
+ warnings.push(`Inline code added: ${renderSpans(addedSpans)}`);
226
+ return invalid(errors, warnings);
227
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Frontmatter reader for the bundled `SKILL.md` files and the compress
3
+ * pipeline.
4
+ *
5
+ * The fast path is a local reader for the flat subset those files use:
6
+ * `key: value` with a plain, single-quoted, or double-quoted scalar, and `>`/`|`
7
+ * block scalars in all three chomping forms. It claims only forms it can prove.
8
+ * A nested map, a list, a flow collection, a key form it does not recognise,
9
+ * or any other shape it is not sure about falls through to `yaml`.
10
+ *
11
+ * "Prove" is the whole contract, so the claimed subset is deliberately narrow:
12
+ * every line of the block must be a blank line, a column-0 comment, or
13
+ * `SIMPLE_KEY: value` with a key the YAML core schema leaves a string and no
14
+ * repeated key; a block that is anything else (a bare scalar, a sequence, an
15
+ * explicit `?` key, an indented continuation) is refused rather than guessed.
16
+ * {@link NON_STRING} is the YAML core schema's own resolution set copied out of
17
+ * `yaml/dist/schema/core`, so a value it does not match is provably a string,
18
+ * and a value it does match falls back instead of being read as one.
19
+ *
20
+ * The fallback is the contract, not an afterthought: `yaml` is never imported
21
+ * at module scope, so a catalog of flat files (every bundled skill) never pays
22
+ * the library's module-load cost. {@link parseFrontmatterAsync} loads it with a
23
+ * dynamic `import('yaml')`; the synchronous {@link parseFrontmatter} loads it
24
+ * with a lazy `createRequire` for the callers that cannot await.
25
+ *
26
+ * Behaviour matches the previous `yaml`-only reader: a block that is not a YAML
27
+ * mapping at all (a bare scalar, a sequence, an empty block) yields no keys and
28
+ * keeps the body, and a block that is malformed YAML throws, which
29
+ * `readSkillFile` turns into a warning and a skipped skill.
30
+ *
31
+ * @module dsh-caveman/frontmatter
32
+ */
33
+ import { createRequire } from 'node:module';
34
+ const FRONTMATTER_BLOCK = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
35
+ /** The only key form the fast path claims to understand. */
36
+ const SIMPLE_KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/;
37
+ /**
38
+ * The plain scalars the YAML core schema resolves to something other than a
39
+ * string.
40
+ *
41
+ * This is the union of the core schema's own tests, copied from
42
+ * `yaml/dist/schema/core` (`int`, `intOct`, `intHex`, `float`, `floatExp`,
43
+ * `floatNaN`, `bool`, `null`). A plain scalar that matches none of them is
44
+ * provably a `tag:yaml.org,2002:str`, which is the only thing the fast path may
45
+ * read as one. Anything it does match (including leading-zero integers such as
46
+ * `01`, which the schema's bare `[-+]?[0-9]+` reads as `1`) falls back.
47
+ */
48
+ const NON_STRING = /^(?:~|[Nn]ull|NULL|[Tt]rue|TRUE|[Ff]alse|FALSE|[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+|[-+]?(?:\.[0-9]+(?:[eE][-+]?[0-9]+)?|[0-9]+\.[0-9]*(?:[eE][-+]?[0-9]+)?|[0-9]+[eE][-+]?[0-9]+)|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
49
+ /** Line starts that make a block something other than a plain scalar. */
50
+ const INDICATOR = '#,[]{}&*!|>\'"%@`';
51
+ /**
52
+ * Characters `String.prototype.trim` strips that YAML does not count as
53
+ * whitespace, so it keeps them as content.
54
+ *
55
+ * YAML's separation space is only space and tab. Every other code point below
56
+ * is content to `yaml` but blank to `trim`/`trimStart`, which is enough to move
57
+ * a block scalar's indentation (`description: |` followed by `\uFEFFname: x` is
58
+ * a key to `yaml`, not body text) or to hide trailing text after a closing
59
+ * quote. Any line carrying one falls back rather than being read either way.
60
+ */
61
+ const JS_ONLY_SPACE = /[\v\f\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]/;
62
+ let syncYaml;
63
+ let asyncYaml;
64
+ /**
65
+ * Load `yaml` synchronously. Only reached by a block the fast path refused, so
66
+ * a catalog of flat files never gets here.
67
+ */
68
+ function loadYamlSync() {
69
+ if (syncYaml === undefined) {
70
+ const require = createRequire(import.meta.url);
71
+ syncYaml = require('yaml');
72
+ }
73
+ return syncYaml;
74
+ }
75
+ /**
76
+ * Load `yaml` with a dynamic import, cached for later fallbacks.
77
+ */
78
+ function loadYaml() {
79
+ asyncYaml ??= import('yaml');
80
+ return asyncYaml;
81
+ }
82
+ /** Add one key without letting a `__proto__` key reach the prototype. */
83
+ function define(target, key, value) {
84
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
85
+ }
86
+ /** Read a `"…"` scalar, or `undefined` when YAML could read the text differently. */
87
+ function readDoubleQuoted(text) {
88
+ let out = '';
89
+ let index = 1;
90
+ while (index < text.length) {
91
+ const char = text[index];
92
+ if (char === '"') {
93
+ if (text.slice(index + 1).trim() !== '')
94
+ return undefined;
95
+ return out;
96
+ }
97
+ if (char === '\\') {
98
+ const escape = text[index + 1];
99
+ if (escape === '\\' || escape === '"')
100
+ out += escape;
101
+ else if (escape === 'n')
102
+ out += '\n';
103
+ else if (escape === 't')
104
+ out += '\t';
105
+ else if (escape === 'r')
106
+ out += '\r';
107
+ else if (escape === '0')
108
+ out += '\0';
109
+ else
110
+ return undefined;
111
+ index += 2;
112
+ continue;
113
+ }
114
+ out += char;
115
+ index += 1;
116
+ }
117
+ return undefined;
118
+ }
119
+ /** Read a `'…'` scalar, or `undefined` when YAML could read the text differently. */
120
+ function readSingleQuoted(text) {
121
+ let out = '';
122
+ let index = 1;
123
+ while (index < text.length) {
124
+ const char = text[index];
125
+ if (char === "'") {
126
+ if (text[index + 1] === "'") {
127
+ out += "'";
128
+ index += 2;
129
+ continue;
130
+ }
131
+ if (text.slice(index + 1).trim() !== '')
132
+ return undefined;
133
+ return out;
134
+ }
135
+ out += char;
136
+ index += 1;
137
+ }
138
+ return undefined;
139
+ }
140
+ /** Read a plain scalar, or `undefined` when YAML could resolve it to anything else. */
141
+ function readPlain(text) {
142
+ if (text === '' || NON_STRING.test(text))
143
+ return undefined;
144
+ if (text.includes('\t') || text.includes(': ') || text.endsWith(':'))
145
+ return undefined;
146
+ if (text.includes(' #'))
147
+ return undefined;
148
+ for (const char of '[]{}')
149
+ if (text.includes(char))
150
+ return undefined;
151
+ if (INDICATOR.includes(text[0] ?? ''))
152
+ return undefined;
153
+ const first = text[0];
154
+ if ((first === '-' || first === '?' || first === ':') && (text.length === 1 || text[1] === ' '))
155
+ return undefined;
156
+ return text;
157
+ }
158
+ /** Read one inline value: block-scalar header, quoted scalar, or plain scalar. */
159
+ function readInline(text, lines, index) {
160
+ const header = /^([|>])([+-]?)$/.exec(text);
161
+ if (header !== null)
162
+ return readBlockScalar(lines, index, header[1] === '>', header[2] ?? '');
163
+ if (text.startsWith('"')) {
164
+ const value = readDoubleQuoted(text);
165
+ return value === undefined ? undefined : { value, next: index + 1 };
166
+ }
167
+ if (text.startsWith("'")) {
168
+ const value = readSingleQuoted(text);
169
+ return value === undefined ? undefined : { value, next: index + 1 };
170
+ }
171
+ const value = readPlain(text);
172
+ return value === undefined ? undefined : { value, next: index + 1 };
173
+ }
174
+ function foldLines(core) {
175
+ let out = '';
176
+ let blanks = 0;
177
+ for (const line of core) {
178
+ if (line === '') {
179
+ blanks += 1;
180
+ continue;
181
+ }
182
+ if (out === '')
183
+ out = line;
184
+ else
185
+ out += (blanks === 0 ? ' ' : '\n'.repeat(blanks)) + line;
186
+ blanks = 0;
187
+ }
188
+ return out;
189
+ }
190
+ /**
191
+ * Read a `>`/`|` block scalar starting after its header line.
192
+ * @param folded - `true` for `>`, `false` for `|`.
193
+ * @param chomp - `''` (clip), `'-'` (strip), or `'+'` (keep).
194
+ * @returns the scalar and the index after the block, or `undefined` when unsure.
195
+ */
196
+ function readBlockScalar(lines, headerIndex, folded, chomp) {
197
+ const content = [];
198
+ let indent = -1;
199
+ let index = headerIndex + 1;
200
+ for (; index < lines.length; index += 1) {
201
+ const line = lines[index];
202
+ if (line === '') {
203
+ content.push('');
204
+ continue;
205
+ }
206
+ // A whitespace-only line is blank to YAML but its residual spaces are
207
+ // content, and a line that is only spaces is not provably one or the other.
208
+ if (line.trim() === '')
209
+ return undefined;
210
+ if (line.includes('\t') || /[ ]$/.test(line))
211
+ return undefined;
212
+ const lead = line.length - line.trimStart().length;
213
+ if (indent === -1) {
214
+ if (lead === 0)
215
+ break;
216
+ indent = lead;
217
+ }
218
+ else if (lead < indent) {
219
+ break;
220
+ }
221
+ else if (lead > indent) {
222
+ return undefined;
223
+ }
224
+ content.push(line.slice(indent));
225
+ }
226
+ let trailing = 0;
227
+ while (content.length > trailing && content[content.length - 1 - trailing] === '')
228
+ trailing += 1;
229
+ const core = content.slice(0, content.length - trailing);
230
+ if (core[0] === '')
231
+ return undefined;
232
+ const body = folded ? foldLines(core) : core.join('\n');
233
+ if (core.length === 0)
234
+ return { value: chomp === '+' ? '\n'.repeat(trailing) : '', next: index };
235
+ if (chomp === '-')
236
+ return { value: body, next: index };
237
+ if (chomp === '+')
238
+ return { value: body + '\n'.repeat(trailing + 1), next: index };
239
+ return { value: `${body}\n`, next: index };
240
+ }
241
+ /**
242
+ * Parse a frontmatter block the fast path can prove it understands.
243
+ *
244
+ * The claim is `SIMPLE_KEY: value` for every non-blank, non-comment line, with
245
+ * a unique key the core schema leaves a string and a value {@link readInline}
246
+ * accepts. Every other shape (a bare scalar, a sequence, an explicit `?` key,
247
+ * an indented line, a whitespace-only line) returns `undefined` so the caller
248
+ * runs the real parser. A block of nothing but blanks and comments is the one
249
+ * non-mapping shape still claimed: `yaml` resolves it to `null`, which the
250
+ * reader reports as no keys.
251
+ * @returns the keys, or `undefined` when the caller must fall back to `yaml`.
252
+ */
253
+ function parseFlat(block) {
254
+ const lines = block.split(/\r\n|\n/);
255
+ // The block never ends with the line terminator it was cut at: a trailing
256
+ // '\n' terminates its last line rather than opening a blank one.
257
+ if (block.endsWith('\n'))
258
+ lines.pop();
259
+ // A bare '\r' is a line break `yaml` may fold in ways this reader does not,
260
+ // and a non-YAML space would make `trim` disagree with `yaml` about content.
261
+ if (JS_ONLY_SPACE.test(block))
262
+ return undefined;
263
+ for (const line of lines)
264
+ if (line.includes('\r'))
265
+ return undefined;
266
+ const data = {};
267
+ let mapping = false;
268
+ let index = 0;
269
+ while (index < lines.length) {
270
+ const line = lines[index];
271
+ if (line === '') {
272
+ index += 1;
273
+ continue;
274
+ }
275
+ // Whitespace-only lines are not provably equivalent to empty ones here.
276
+ if (line.trim() === '')
277
+ return undefined;
278
+ if (line.startsWith('#')) {
279
+ index += 1;
280
+ continue;
281
+ }
282
+ const colon = line.indexOf(':');
283
+ const key = colon === -1 ? '' : line.slice(0, colon);
284
+ const spaced = colon !== -1 && (line[colon + 1] === ' ' || line[colon + 1] === '\t');
285
+ // A key the core schema resolves to a bool or null lands in the object
286
+ // under a different name, and a repeated key makes `yaml` throw.
287
+ if (key === '' || !SIMPLE_KEY.test(key) || NON_STRING.test(key) || !spaced)
288
+ return undefined;
289
+ if (Object.hasOwn(data, key))
290
+ return undefined;
291
+ mapping = true;
292
+ const read = readInline(line.slice(colon + 1).trim(), lines, index);
293
+ if (read === undefined)
294
+ return undefined;
295
+ define(data, key, read.value);
296
+ index = read.next;
297
+ }
298
+ return mapping ? data : {};
299
+ }
300
+ function splitFrontmatter(source) {
301
+ const text = source.replace(/^\uFEFF/, '');
302
+ const match = FRONTMATTER_BLOCK.exec(text);
303
+ if (match === null)
304
+ return { raw: '', block: '', body: text, present: false };
305
+ const raw = match[0];
306
+ // The body keeps its own bytes apart from line terminators, which are
307
+ // normalized the way the previous line-split reader normalized them.
308
+ return { raw, block: match[1] ?? '', body: text.slice(raw.length).replace(/\r\n/g, '\n'), present: true };
309
+ }
310
+ function fromParsed(raw, body, parsed) {
311
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
312
+ return { raw, data: {}, body };
313
+ }
314
+ return { raw, data: parsed, body };
315
+ }
316
+ /**
317
+ * Parse leading YAML frontmatter from a markdown document.
318
+ *
319
+ * For callers that cannot await. A block the fast path refuses loads `yaml`
320
+ * synchronously; use {@link parseFrontmatterAsync} on paths where deferring the
321
+ * library to a dynamic import matters.
322
+ * @param source - full file contents.
323
+ * @returns the verbatim block, the parsed keys, and the remaining body.
324
+ */
325
+ export function parseFrontmatter(source) {
326
+ const parts = splitFrontmatter(source);
327
+ if (!parts.present)
328
+ return { raw: '', data: {}, body: parts.body };
329
+ const flat = parseFlat(parts.block);
330
+ return fromParsed(parts.raw, parts.body, flat ?? loadYamlSync().parse(parts.block));
331
+ }
332
+ /**
333
+ * Parse leading YAML frontmatter, loading `yaml` with a dynamic import only
334
+ * when the fast path refuses the block.
335
+ * @param source - full file contents.
336
+ * @returns the verbatim block, the parsed keys, and the remaining body.
337
+ */
338
+ export async function parseFrontmatterAsync(source) {
339
+ const parts = splitFrontmatter(source);
340
+ if (!parts.present)
341
+ return { raw: '', data: {}, body: parts.body };
342
+ const flat = parseFlat(parts.block);
343
+ if (flat !== undefined)
344
+ return fromParsed(parts.raw, parts.body, flat);
345
+ const { parse } = await loadYaml();
346
+ return fromParsed(parts.raw, parts.body, parse(parts.block));
347
+ }
package/lib/host.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The slice of the DeepSeek Harness host surface this plugin uses, declared
3
+ * structurally.
4
+ *
5
+ * The plugin is installed from outside the harness checkout. It depends on the
6
+ * published `@deepseek-ai/dsh-tools` and `@deepseek-ai/dsh-skill` packages: the
7
+ * skill domain shapes (summaries, candidates, definitions, lookup options) are
8
+ * imported from the latter, while the services it reaches through
9
+ * `ctx.inject([...])` are declared structurally here: the host types remain
10
+ * authoritative, and a composition that does not mount a service simply omits
11
+ * that capability.
12
+ *
13
+ * @module dsh-caveman/host
14
+ */
15
+ export {};