@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
package/lib/modes.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Caveman's level model: the accepted levels, their normalization, the
3
+ * mode-specific filter over the `caveman` skill body, and the instruction
4
+ * block the plugin injects into the system prompt.
5
+ *
6
+ * Levels mirror upstream (`JuliusBrussee/caveman`, MIT): lite, full, ultra
7
+ * and the three wenyan variants. Unlike ponytail there is no session-only
8
+ * level: every level persists. The one addition over upstream is the
9
+ * `wenyan` shorthand for `wenyan-full`, accepted by the `/caveman` command.
10
+ *
11
+ * @module dsh-caveman/modes
12
+ */
13
+ /** Every accepted level; all persist as a default. */
14
+ export const RUNTIME_MODES = [
15
+ 'off',
16
+ 'lite',
17
+ 'full',
18
+ 'ultra',
19
+ 'wenyan-lite',
20
+ 'wenyan-full',
21
+ 'wenyan-ultra',
22
+ ];
23
+ /** Level used when neither config nor environment sets one. */
24
+ export const DEFAULT_MODE = 'full';
25
+ /**
26
+ * Normalize a value to a level that may be persisted as a default.
27
+ * @param value - candidate level from a config field, environment, or command.
28
+ * @returns the canonical runtime level, or `undefined` when unrecognized.
29
+ */
30
+ export function normalizeMode(value) {
31
+ if (typeof value !== 'string')
32
+ return undefined;
33
+ const normalized = value.trim().toLowerCase();
34
+ return RUNTIME_MODES.find((mode) => mode === normalized);
35
+ }
36
+ /**
37
+ * Whether a whole message is a deactivation command.
38
+ *
39
+ * "stop caveman" / "normal mode" turn caveman off, but only as a standalone
40
+ * command: matching the phrase anywhere in a message turned it off mid-task for
41
+ * ordinary requests like "add a normal mode toggle", so the whole trimmed
42
+ * message must be the command, ignoring case and trailing punctuation.
43
+ * @param text - user message text.
44
+ * @returns whether the message is the deactivation command.
45
+ */
46
+ export function isDeactivationCommand(text) {
47
+ const normalized = String(text ?? '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
48
+ return normalized === 'stop caveman' || normalized === 'normal mode';
49
+ }
50
+ /**
51
+ * Resolve a human `/caveman` argument to a level, including the `wenyan`
52
+ * shorthand for `wenyan-full` (upstream: `/caveman wenyan` means full 文言文).
53
+ * @param input - trimmed, lowercased command input.
54
+ * @returns the canonical level, or `undefined` when unrecognized.
55
+ */
56
+ export function normalizeCommandMode(input) {
57
+ if (input === 'wenyan')
58
+ return 'wenyan-full';
59
+ return normalizeMode(input);
60
+ }
61
+ /**
62
+ * Resolve the configured level and the source that decided it.
63
+ *
64
+ * Order: this plugin's config field, then `CAVEMAN_DEFAULT_MODE`, then the
65
+ * upstream config file (`~/.config/caveman/config.json`), then `full`.
66
+ * @param sources - injectable overrides for tests.
67
+ * @returns the level and its source.
68
+ */
69
+ export function resolveDefaultMode(sources = {}) {
70
+ const configured = normalizeMode(sources.configured);
71
+ if (configured !== undefined)
72
+ return { mode: configured, source: 'settings' };
73
+ const envMode = normalizeMode((sources.env ?? process.env)['CAVEMAN_DEFAULT_MODE']);
74
+ if (envMode !== undefined)
75
+ return { mode: envMode, source: 'env' };
76
+ const fileMode = normalizeMode(sources.configFile?.defaultMode);
77
+ if (fileMode !== undefined)
78
+ return { mode: fileMode, source: 'config-file' };
79
+ return { mode: DEFAULT_MODE, source: 'default' };
80
+ }
81
+ /**
82
+ * Drop the intensity-table rows and worked examples that belong to other
83
+ * levels.
84
+ *
85
+ * Only the intensity table rows and worked examples are mode-specific, and both
86
+ * are keyed by a level name. A bullet whose label is not a level (e.g.
87
+ * "Never drop not/never/no/only/except ...") is a normal rule and stays
88
+ * verbatim; the quoted-value requirement on examples is what keeps a rule that
89
+ * merely starts with a level word from being dropped in every other mode.
90
+ * @param body - markdown of the `caveman` skill, frontmatter already removed.
91
+ * @param mode - the level to keep.
92
+ * @returns the body with other levels' rows and examples removed.
93
+ */
94
+ export function filterSkillBodyForMode(body, mode) {
95
+ const effective = normalizeMode(mode) ?? DEFAULT_MODE;
96
+ return String(body ?? '')
97
+ .split(/\r?\n/)
98
+ .filter((line) => {
99
+ const tableLabel = /^\|\s*\*\*(.+?)\*\*\s*\|/.exec(line);
100
+ if (tableLabel?.[1] !== undefined) {
101
+ const labelMode = normalizeMode(tableLabel[1].trim());
102
+ if (labelMode !== undefined)
103
+ return labelMode === effective;
104
+ }
105
+ const exampleLabel = /^-\s*([^:]+):\s*"/.exec(line);
106
+ if (exampleLabel?.[1] !== undefined) {
107
+ const labelMode = normalizeMode(exampleLabel[1].trim());
108
+ if (labelMode !== undefined)
109
+ return labelMode === effective;
110
+ }
111
+ return true;
112
+ })
113
+ .join('\n');
114
+ }
115
+ /**
116
+ * Build the exact text the system prompt carries for one level.
117
+ * @param input - the active level and the skill body.
118
+ * @returns the instruction block, or `''` when the level is `off`.
119
+ */
120
+ export function buildModeInstructions(input) {
121
+ const { mode } = input;
122
+ if (mode === 'off')
123
+ return '';
124
+ const effective = normalizeMode(mode) ?? DEFAULT_MODE;
125
+ return `CAVEMAN MODE ACTIVE — level: ${effective}\n\n${filterSkillBodyForMode(input.skillBody, effective)}`;
126
+ }
package/lib/skills.js ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The bundled caveman skills as a `ctx.skills` provider.
3
+ *
4
+ * Skills are read from this package's `skills/<name>/SKILL.md`, so the same
5
+ * files stay the single source of truth for both the always-on ruleset (which
6
+ * filters the `caveman` body per level) and the on-demand skills.
7
+ *
8
+ * @module dsh-caveman/skills
9
+ */
10
+ import { readdir, readFile } from 'node:fs/promises';
11
+ import { basename, dirname, join } from 'node:path';
12
+ import { BUNDLED_SKILL_RANK, isSkillName } from '@deepseek-ai/dsh-skill';
13
+ import { parseFrontmatterAsync } from './frontmatter.js';
14
+ /** Provider name inside the skill registry. */
15
+ const PROVIDER_NAME = 'caveman';
16
+ /** Instruction file every skill directory must carry. */
17
+ const INSTRUCTION_FILE = 'SKILL.md';
18
+ /**
19
+ * Frontmatter keys that already have a first-class home on the summary: they
20
+ * are projected into `name`, `description`, `whenToUse`, and `invocation`, so
21
+ * repeating them in `metadata` would only duplicate the domain model.
22
+ */
23
+ const PROJECTED_KEYS = new Set([
24
+ 'name',
25
+ 'description',
26
+ 'whenToUse',
27
+ 'disable-model-invocation',
28
+ 'user-invocable',
29
+ ]);
30
+ /** Read a frontmatter value as a non-empty trimmed string. */
31
+ function readString(value) {
32
+ return typeof value === 'string' ? value.trim() : '';
33
+ }
34
+ /**
35
+ * Read and parse one skill file. Shared by discovery and direct loads so a
36
+ * single file enforces the name/description/frontmatter rules everywhere.
37
+ * @param path - absolute path of the `SKILL.md` file.
38
+ * @param onWarn - optional non-fatal problem sink.
39
+ * @param entryName - directory name fallback when frontmatter omits `name`.
40
+ * @returns the parsed skill, or `undefined` with a warning when invalid.
41
+ */
42
+ async function readSkillFile(path, onWarn, entryName) {
43
+ let source;
44
+ try {
45
+ source = await readFile(path, 'utf8');
46
+ }
47
+ catch (error) {
48
+ // A directory without an instruction file is a broken skill, not an empty
49
+ // one: `discoverSkills` promises it reaches the same sink as every other
50
+ // skipped skill.
51
+ onWarn?.(`cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`);
52
+ return undefined;
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = await parseFrontmatterAsync(source);
57
+ }
58
+ catch (error) {
59
+ onWarn?.(`skipping ${path}: ${error instanceof Error ? error.message : String(error)}`);
60
+ return undefined;
61
+ }
62
+ const fallback = entryName ?? basename(path);
63
+ const name = readString(parsed.data['name']) || fallback;
64
+ const description = readString(parsed.data['description']);
65
+ const whenToUse = readString(parsed.data['whenToUse']);
66
+ if (!isSkillName(name)) {
67
+ onWarn?.(`skipping ${path}: "${name}" is not a valid kebab-case skill name`);
68
+ return undefined;
69
+ }
70
+ if (description === '') {
71
+ onWarn?.(`skipping ${path}: frontmatter has no description`);
72
+ return undefined;
73
+ }
74
+ const metadata = {};
75
+ for (const [key, value] of Object.entries(parsed.data)) {
76
+ if (!PROJECTED_KEYS.has(key))
77
+ metadata[key] = value;
78
+ }
79
+ return {
80
+ name,
81
+ description,
82
+ ...(whenToUse === '' ? {} : { whenToUse }),
83
+ // The canonical keys, defaulted the documented way: only an explicit `true`
84
+ // disables model invocation, only an explicit `false` disables user
85
+ // invocation.
86
+ invocation: {
87
+ modelInvocable: parsed.data['disable-model-invocation'] !== true,
88
+ userInvocable: parsed.data['user-invocable'] !== false,
89
+ },
90
+ content: parsed.body.trim(),
91
+ metadata,
92
+ path,
93
+ directory: dirname(path),
94
+ };
95
+ }
96
+ /**
97
+ * Read every valid skill directory under `skillsDir`.
98
+ *
99
+ * A missing directory, a directory without `SKILL.md`, a file whose frontmatter
100
+ * the reader refuses, and a file with a missing description are reported
101
+ * through `onWarn` and skipped: one broken file must not cost the catalog its
102
+ * other skills.
103
+ * @param skillsDir - directory holding one subdirectory per skill.
104
+ * @param onWarn - optional non-fatal problem sink.
105
+ * @returns the parsed skills, sorted by name.
106
+ */
107
+ export async function discoverSkills(skillsDir, onWarn) {
108
+ let entries;
109
+ try {
110
+ entries = await readdir(skillsDir, { withFileTypes: true });
111
+ }
112
+ catch (error) {
113
+ onWarn?.(`cannot read skills directory ${skillsDir}: ${error instanceof Error ? error.message : String(error)}`);
114
+ return [];
115
+ }
116
+ const skills = [];
117
+ for (const entry of entries) {
118
+ if (!entry.isDirectory())
119
+ continue;
120
+ const path = join(skillsDir, entry.name, INSTRUCTION_FILE);
121
+ const skill = await readSkillFile(path, onWarn, entry.name);
122
+ if (skill !== undefined)
123
+ skills.push(skill);
124
+ }
125
+ return skills.sort((left, right) => left.name.localeCompare(right.name));
126
+ }
127
+ /**
128
+ * Build the provider the skill registry mounts.
129
+ * @param options - skills directory and the non-fatal problem sink.
130
+ * @returns a provider whose candidates are summaries and whose bodies come from disk.
131
+ */
132
+ export function createSkillProvider(options) {
133
+ const summaryOf = (skill) => ({
134
+ path: skill.path,
135
+ name: skill.name,
136
+ description: skill.description,
137
+ ...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
138
+ invocation: skill.invocation,
139
+ source: 'bundled',
140
+ provider: PROVIDER_NAME,
141
+ resourceBase: { kind: 'directory', path: skill.directory },
142
+ });
143
+ return {
144
+ name: PROVIDER_NAME,
145
+ async list(lookup = {}) {
146
+ lookup.signal?.throwIfAborted();
147
+ let complete = true;
148
+ const skills = await discoverSkills(options.skillsDir, (message) => {
149
+ complete = false;
150
+ options.onWarn?.(message);
151
+ });
152
+ lookup.signal?.throwIfAborted();
153
+ const candidates = skills.map((skill) => ({
154
+ ...summaryOf(skill),
155
+ rank: BUNDLED_SKILL_RANK,
156
+ locator: skill.path,
157
+ metadata: skill.metadata,
158
+ }));
159
+ return complete ? candidates : { candidates, complete: false };
160
+ },
161
+ async get(candidate, lookup = {}) {
162
+ if (typeof candidate.locator !== 'string')
163
+ return undefined;
164
+ lookup.signal?.throwIfAborted();
165
+ // Read the locator directly: one file instead of a full re-discovery.
166
+ // The directory name is the fallback discovery used, so a skill whose
167
+ // frontmatter omits `name` loads under the name `list` reported. The
168
+ // name check keeps a stale candidate (path reused by another skill) from
169
+ // loading under the wrong identity.
170
+ const skill = await readSkillFile(candidate.locator, options.onWarn, basename(dirname(candidate.locator)));
171
+ lookup.signal?.throwIfAborted();
172
+ if (skill === undefined || skill.name !== candidate.name)
173
+ return undefined;
174
+ return { ...summaryOf(skill), content: skill.content, metadata: skill.metadata };
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Detect whether a file is natural language (compressible) or code/config.
3
+ *
4
+ * TypeScript port of `skills/caveman-compress/scripts/detect.py` (MIT,
5
+ * © JuliusBrussee). The Python original is dropped: the ported pipeline runs
6
+ * in-process with no `python3` requirement.
7
+ *
8
+ * @module dsh-caveman/compress-detect
9
+ */
10
+ /** File classification. */
11
+ export type FileType = 'natural_language' | 'code' | 'config' | 'unknown';
12
+ /**
13
+ * Classify a file as natural language, code, config, or unknown.
14
+ * @param basename - file basename (extension rules key off this).
15
+ * @param readText - reads the file when content sniffing is needed; throw to signal unreadable.
16
+ * @returns the classification.
17
+ */
18
+ export declare function detectFileType(basename: string, readText?: () => string): FileType;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * File handling for the compress pipeline: sensitive path refusal, atomic
3
+ * writes, backups, and source reading.
4
+ *
5
+ * TypeScript port of the non-model parts of
6
+ * `skills/caveman-compress/scripts/compress.py` (MIT, © JuliusBrussee).
7
+ * The `callClaude` half is deliberately not ported: this plugin compresses
8
+ * with deterministic local rules instead (see `compress-rules.ts`). The
9
+ * Python original is dropped.
10
+ *
11
+ * @module dsh-caveman/compress-files
12
+ */
13
+ /**
14
+ * Packaged default for the `maxFileSize` cap (500000 bytes, ~500 KB), used when
15
+ * the plugin row does not override it. `apply` validates the configured value
16
+ * and threads it through the pipeline; this is only the default.
17
+ */
18
+ export declare const MAX_FILE_SIZE = 500000;
19
+ /**
20
+ * Heuristic denylist for files that must never be rewritten by a tool that
21
+ * ships bytes to a model. Fail loudly rather than exfiltrate.
22
+ * @param filePath - absolute file path.
23
+ * @returns true when the path looks sensitive.
24
+ */
25
+ export declare function isSensitivePath(filePath: string): boolean;
26
+ /**
27
+ * Out-of-tree backup dir for a file, keyed by its parent dir name, kept
28
+ * outside the source tree so skill auto-loaders don't re-ingest backups.
29
+ * @param filePath - absolute source path.
30
+ * @returns the backup directory.
31
+ */
32
+ export declare function backupDirFor(filePath: string): string;
33
+ /**
34
+ * Backup file path for a source file.
35
+ * @param filePath - absolute source path.
36
+ * @returns the `.original.md` backup path.
37
+ */
38
+ export declare function backupPathFor(filePath: string): string;
39
+ /** A write syscall-shaped function: bytes written, possibly fewer than asked. */
40
+ export type WriteCall = (fd: number, buffer: Buffer, offset: number, length: number) => number;
41
+ /**
42
+ * Write bytes atomically: sibling temp file, fsync, rename. Loops over short
43
+ * writes so the temp file is never a truncated prefix of `data`. Preserves the
44
+ * destination's permission bits across the swap.
45
+ * @param filePath - destination path.
46
+ * @param data - bytes to write.
47
+ * @param write - write syscall seam; defaults to `fs.writeSync`.
48
+ * @param exclusive - publish a backup only if its destination does not exist.
49
+ */
50
+ export declare function writeBytesAtomic(filePath: string, data: Buffer, write?: WriteCall, exclusive?: boolean): void;
51
+ /**
52
+ * Write text atomically as UTF-8, preserving the document's line terminator.
53
+ * @param filePath - destination path.
54
+ * @param text - text with `\n` line endings.
55
+ * @param newline - line terminator to emit.
56
+ */
57
+ export declare function writeTextAtomic(filePath: string, text: string, newline?: '\n' | '\r\n'): void;
58
+ /** A source file read exactly: decoded text, line terminator, raw bytes. */
59
+ export interface SourceFile {
60
+ readonly text: string;
61
+ readonly newline: '\n' | '\r\n';
62
+ readonly raw: Buffer;
63
+ /** True when the file started with a UTF-8 BOM, stripped from `text`. */
64
+ readonly bom: boolean;
65
+ }
66
+ /**
67
+ * Read a source file strictly as UTF-8. A file that cannot be decoded
68
+ * exactly is refused: the round trip would destroy bytes.
69
+ *
70
+ * A leading BOM is detected from the raw bytes and reported separately: the
71
+ * decoder drops it, and the caller re-attaches it when writing so the byte
72
+ * survives the round trip.
73
+ * @param filePath - absolute source path.
74
+ * @returns text (LF-normalized), terminator, raw bytes for the backup, BOM flag.
75
+ */
76
+ export declare function readSource(filePath: string): SourceFile;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The compress pipeline: detect, guard, rewrite locally, validate, back up,
3
+ * and write. TypeScript port of the orchestration in
4
+ * `skills/caveman-compress/scripts/compress.py` (MIT, © JuliusBrussee) with
5
+ * one deliberate difference: the rewrite step uses deterministic local rules
6
+ * (`compress-rules.ts`) instead of a model call, so no file bytes ever leave
7
+ * the machine and no API key or CLI is needed.
8
+ *
9
+ * Fail-closed throughout: sensitive paths refuse, oversized files refuse,
10
+ * non-UTF-8 refuses, an existing backup aborts, a candidate that is not
11
+ * smaller aborts, and a candidate that fails validation aborts. The live
12
+ * file is written only after a passing validation.
13
+ *
14
+ * @module dsh-caveman/compress-pipeline
15
+ */
16
+ /** Why a compression run refused or failed. */
17
+ export type CompressOutcome = {
18
+ readonly ok: true;
19
+ readonly backupPath: string;
20
+ readonly originalChars: number;
21
+ readonly compressedChars: number;
22
+ } | {
23
+ readonly ok: false;
24
+ readonly reason: string;
25
+ };
26
+ /**
27
+ * Compress one file in place, keeping an out-of-tree backup.
28
+ * @param inputPath - file to compress.
29
+ * @param maxFileSize - configured size cap in bytes; defaults to the packaged 500000.
30
+ * @returns the outcome; the file is untouched unless `ok` is true.
31
+ */
32
+ export declare function compressFile(inputPath: string, maxFileSize?: number): CompressOutcome;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Deterministic local compression rules: the caveman style without a model.
3
+ *
4
+ * This replaces the `callClaude` half of
5
+ * `skills/caveman-compress/scripts/compress.py` (MIT, © JuliusBrussee),
6
+ * which this plugin deliberately does not port: shipping file bytes to a
7
+ * third-party model for a local rewrite is the "dumb feature" this port
8
+ * leaves out. Everything else (detect, validate, file handling) is ported
9
+ * faithfully; only the rewrite step is local and rule-based.
10
+ *
11
+ * Rules mirror the skill's Compression Rules section: drop articles, filler,
12
+ * pleasantries, hedging, and connective fluff; shorten redundant phrasing;
13
+ * keep code/URLs/paths/commands/technical terms/numbers byte-identical;
14
+ * never touch headings. Code spans (fenced, indented, inline) are masked
15
+ * before rewriting and restored after, so prose rules cannot reach them.
16
+ *
17
+ * @module dsh-caveman/compress-rules
18
+ */
19
+ /**
20
+ * Mask fenced and indented code blocks with opaque markers.
21
+ *
22
+ * The output is assembled from the *gaps between* blocks instead of one string
23
+ * per line: a document with no code returns the input string itself, and a
24
+ * document with three blocks pushes a handful of segments rather than tens of
25
+ * thousands of line references and then joins them all. The result is
26
+ * identical: each segment is a run of whole lines and the join puts the same
27
+ * `\n` separators back.
28
+ * @param text - markdown body.
29
+ * @returns masked text plus the blocks for restoration.
30
+ */
31
+ export declare function maskCodeBlocks(text: string): {
32
+ masked: string;
33
+ blocks: string[];
34
+ };
35
+ /**
36
+ * Restore masked code blocks exactly; fail closed on tampering.
37
+ * @param text - rewritten text with markers.
38
+ * @param blocks - blocks from {@link maskCodeBlocks}.
39
+ * @returns text with code restored.
40
+ */
41
+ export declare function restoreCodeBlocks(text: string, blocks: string[]): string;
42
+ /**
43
+ * Compress one prose line: drop fluff, tighten phrasing, collapse space.
44
+ * Inline code spans (`` `...` ``) are masked first so rules skip them.
45
+ * @param line - a single non-heading, non-code line.
46
+ * @returns the compressed line.
47
+ */
48
+ export declare function compressLine(line: string): string;
49
+ /**
50
+ * Compress a markdown body with local rules. Headings pass through
51
+ * byte-identical; list markers and table pipes are preserved; everything
52
+ * else goes through {@link compressLine}. Empty results fall back to the
53
+ * original line so structure never collapses.
54
+ * @param body - markdown body (frontmatter already removed).
55
+ * @returns the compressed body.
56
+ */
57
+ export declare function compressBody(body: string): string;
58
+ /**
59
+ * Whether the candidate actually compresses the body (strictly smaller).
60
+ * Guard against a rewrite that validates structurally but saves nothing.
61
+ * @param candidate - compressed body.
62
+ * @param body - original body.
63
+ * @returns true when strictly smaller.
64
+ */
65
+ export declare function isSmaller(candidate: string, body: string): boolean;
@@ -0,0 +1,36 @@
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
+ /** Outcome of validating one original/compressed pair. */
12
+ export interface ValidationResult {
13
+ readonly isValid: boolean;
14
+ readonly errors: readonly string[];
15
+ readonly warnings: readonly string[];
16
+ }
17
+ export declare function extractHeadings(text: string): [string, string][];
18
+ /**
19
+ * Every fenced and indented code block, in document order.
20
+ *
21
+ * The masker already owns the definition of "code block" for the rewriter; the
22
+ * validator reuses it so both agree on what must survive byte-identical.
23
+ * @param text - markdown body.
24
+ * @returns the block texts.
25
+ */
26
+ export declare function extractCodeBlocks(text: string): string[];
27
+ export declare function extractUrls(text: string): Set<string>;
28
+ export declare function extractPaths(text: string): Set<string>;
29
+ export declare function extractInlineCodes(text: string): string[];
30
+ /**
31
+ * Validate a compressed candidate against its original.
32
+ * @param original - original file text.
33
+ * @param compressed - compressed candidate text.
34
+ * @returns errors (fail-closed) and warnings (drift notes).
35
+ */
36
+ export declare function validate(original: string, compressed: string): ValidationResult;
@@ -0,0 +1,57 @@
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
+ /** Parsed frontmatter plus the markdown body that follows it. */
34
+ export interface Frontmatter {
35
+ /** The verbatim frontmatter block including both delimiters, or `''` when absent. */
36
+ readonly raw: string;
37
+ readonly data: Readonly<Record<string, unknown>>;
38
+ /** Everything after the closing delimiter, or the whole source when absent. */
39
+ readonly body: string;
40
+ }
41
+ /**
42
+ * Parse leading YAML frontmatter from a markdown document.
43
+ *
44
+ * For callers that cannot await. A block the fast path refuses loads `yaml`
45
+ * synchronously; use {@link parseFrontmatterAsync} on paths where deferring the
46
+ * library to a dynamic import matters.
47
+ * @param source - full file contents.
48
+ * @returns the verbatim block, the parsed keys, and the remaining body.
49
+ */
50
+ export declare function parseFrontmatter(source: string): Frontmatter;
51
+ /**
52
+ * Parse leading YAML frontmatter, loading `yaml` with a dynamic import only
53
+ * when the fast path refuses the block.
54
+ * @param source - full file contents.
55
+ * @returns the verbatim block, the parsed keys, and the remaining body.
56
+ */
57
+ export declare function parseFrontmatterAsync(source: string): Promise<Frontmatter>;