@supersuit/superskill 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 (2026-09-28)
4
+
5
+ - An unquoted value that is only a comment now reads as empty, which is what YAML means. Before
6
+ this, `source: # TODO` came back as the string "# TODO" and a list item written as `- # none
7
+ yet` came back as "# none yet", so a downstream linter counted a comment placeholder as a
8
+ filled-in field and passed specs it should have failed. A comment-only value followed by a
9
+ more-indented block still opens that nested map or list, exactly as it did with no comment.
10
+ Quoted values are untouched: `key: "# literal"` still reads as "# literal".
11
+
12
+ ## 0.2.0 (2026-09-28)
13
+
14
+ - The frontmatter reader reads nesting: maps inside maps, lists of maps, lists inside maps, and
15
+ block scalars at any depth. Until now it stopped at one level and silently ignored anything
16
+ deeper, so a list of maps came back empty. Values still stay strings, and no skill in two real
17
+ corpora (295 skills) changed level. It is exported as `parseYamlSubset` so other standards in
18
+ this family (hyperspecification first) read their files with this one reader instead of a
19
+ second copy.
20
+ - `metadata-string-map` now names a metadata value that is itself a map; before, the reader
21
+ dropped it and the rule never saw it.
22
+
3
23
  ## 0.1.0 (2026-09-28)
4
24
 
5
25
  - `reference-says-when`: a link from SKILL.md to an instruction file must say when to read it. Long-skill fixes now point at step files (`steps/<step>.md`).
package/SPEC.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The superskill standard
2
2
 
3
- **Version 0.1.0** (2026-09-28). The reference checker is `@supersuit/superskill`; where this
3
+ **Version 0.2.1** (2026-09-28). The reference checker is `@supersuit/superskill`; where this
4
4
  document and the checker disagree, the checker has a bug.
5
5
 
6
6
  A **superskill** runs on frontier intelligence, is checked against examples a person approved,
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@supersuit/superskill",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Score any agent skill folder as skill, tested, or superskill. An open standard and a zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": { "superskill": "bin/superskill.mjs" },
7
+ "exports": {
8
+ "./yaml": "./src/frontmatter.mjs",
9
+ "./package.json": "./package.json"
10
+ },
7
11
  "files": ["bin/", "src/", "SPEC.md", "README.md", "CHANGELOG.md", "LICENSE"],
8
12
  "scripts": { "test": "node --test test/*.test.mjs" },
9
13
  "engines": { "node": ">=20" },
@@ -1,5 +1,6 @@
1
- // A small YAML frontmatter reader for the subset SKILL.md files use: scalars, quoted
2
- // strings, folded (>) and literal (|) blocks, one-level maps, inline and block lists.
1
+ // A small YAML frontmatter reader for the subset SKILL.md files and hyperspecs use: scalars,
2
+ // quoted strings, folded (>) and literal (|) blocks, maps and lists nested to any depth by
3
+ // indentation, and inline lists.
3
4
  // Values stay strings; rules decide what a string means. Zero dependencies on purpose.
4
5
 
5
6
  export function parseSkillFile(text) {
@@ -22,55 +23,109 @@ export function parseSkillFile(text) {
22
23
 
23
24
  const indentOf = (l) => l.length - l.trimStart().length;
24
25
 
26
+ // Nesting, added in 0.2.0: maps inside maps, lists of maps, lists inside maps, block scalars
27
+ // at any depth, read by indentation. Until then the reader stopped at one level and IGNORED
28
+ // anything deeper, so a list of maps came back as nothing and no error said so. Values still
29
+ // stay strings. A stray indented line at the top level is still tolerated, as before.
30
+ // 0.2.1: an unquoted value or list item that is only a comment (`key: # TODO`, `- # none yet`)
31
+ // reads as empty, the same as no value at all, which is what YAML means. A comment-only value
32
+ // that is followed by a more-indented block still opens that nested map or list, exactly as
33
+ // `key:` with nothing after it does.
25
34
  export function parseYamlSubset(lines) {
35
+ return parseMap(lines, 0, 0, true)[0];
36
+ }
37
+
38
+ const isBlank = (l) => !l.trim() || l.trimStart().startsWith("#");
39
+ const isItem = (t) => t === "-" || t.startsWith("- ");
40
+ const KEY = /^([^:\s"'][^:]*?|"[^"]*"|'[^']*')\s*:(?:\s+(.*)|\s*)$/;
41
+
42
+ function nextContent(lines, i) {
43
+ while (i < lines.length && isBlank(lines[i])) i++;
44
+ return i;
45
+ }
46
+
47
+ function parseMap(lines, i, indent, top = false) {
26
48
  const out = {};
27
- let i = 0;
28
49
  while (i < lines.length) {
50
+ if (isBlank(lines[i])) { i++; continue; }
29
51
  const line = lines[i];
30
- if (!line.trim() || line.trimStart().startsWith("#")) { i++; continue; }
31
- if (indentOf(line) > 0) { i++; continue; } // stray indented line: tolerate
32
- const m = line.match(/^([^:\s][^:]*?)\s*:(?:\s+(.*)|\s*)$/);
52
+ const ind = indentOf(line);
53
+ if (ind < indent) break;
54
+ if (ind > indent) {
55
+ if (top) { i++; continue; } // stray indented line at the top: tolerate, as before
56
+ break;
57
+ }
58
+ const t = line.trim();
59
+ if (isItem(t)) break;
60
+ const m = t.match(KEY);
33
61
  if (!m) throw new Error(`cannot read line ${i + 1}: ${line.slice(0, 60)}`);
34
62
  const key = m[1].trim().replace(/^["']|["']$/g, "");
35
63
  const rest = (m[2] ?? "").trim();
36
64
  i++;
37
65
  if (/^[>|][+-]?$/.test(rest)) {
38
- const block = [];
39
- while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > 0)) { block.push(lines[i]); i++; }
40
- while (block.length && !block[block.length - 1].trim()) block.pop();
41
- const min = Math.min(...block.filter((l) => l.trim()).map(indentOf));
42
- const stripped = block.map((l) => l.slice(Number.isFinite(min) ? min : 0));
43
- out[key] = rest[0] === "|" ? stripped.join("\n") : foldLines(stripped);
44
- continue;
66
+ const [v, next] = readBlock(lines, i, ind, rest[0]);
67
+ out[key] = v; i = next; continue;
45
68
  }
46
- if (rest === "") {
47
- const child = [];
48
- while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > 0 || lines[i].trimStart().startsWith("- "))) {
49
- if (indentOf(lines[i]) === 0 && lines[i].trim() && !lines[i].startsWith("- ")) break;
50
- child.push(lines[i]); i++;
51
- }
52
- const items = child.filter((l) => l.trim() && !l.trim().startsWith("#"));
53
- if (!items.length) out[key] = "";
54
- else if (items[0].trim().startsWith("- ") || items[0].trim() === "-") out[key] = items.map((l) => scalar(l.trim().replace(/^-\s*/, "")));
55
- else {
56
- const map = {};
57
- const base = indentOf(items[0]);
58
- for (const l of items) {
59
- if (indentOf(l) !== base) continue; // deeper nesting is outside the subset; ignored
60
- const mm = l.trim().match(/^([^:]+?)\s*:\s*(.*)$/);
61
- if (mm) map[mm[1].trim().replace(/^["']|["']$/g, "")] = scalar(mm[2]);
62
- }
63
- out[key] = map;
69
+ if (rest === "" || rest.startsWith("#")) {
70
+ const j = nextContent(lines, i);
71
+ if (j < lines.length) {
72
+ const ci = indentOf(lines[j]);
73
+ const ct = lines[j].trim();
74
+ if (ci > ind) { [out[key], i] = isItem(ct) ? parseList(lines, j, ci) : parseMap(lines, j, ci); continue; }
75
+ if (ci === ind && isItem(ct)) { [out[key], i] = parseList(lines, j, ci); continue; }
64
76
  }
65
- continue;
77
+ out[key] = ""; continue;
66
78
  }
67
- if (rest.startsWith("[") && rest.endsWith("]")) {
68
- out[key] = splitInline(rest.slice(1, -1)).map(scalar).filter((s) => s !== "");
79
+ out[key] = inlineOrScalar(rest);
80
+ }
81
+ return [out, i];
82
+ }
83
+
84
+ function parseList(lines, i, indent) {
85
+ const out = [];
86
+ while (i < lines.length) {
87
+ if (isBlank(lines[i])) { i++; continue; }
88
+ const line = lines[i];
89
+ const ind = indentOf(line);
90
+ if (ind < indent) break;
91
+ if (ind > indent) { i++; continue; }
92
+ const t = line.trim();
93
+ if (!isItem(t)) break;
94
+ const content = t === "-" ? "" : t.slice(1).trimStart();
95
+ const at = line.indexOf(content, ind + 1); // where the item's content starts on the line
96
+ i++;
97
+ if (content === "" || content.startsWith("#")) {
98
+ const j = nextContent(lines, i);
99
+ if (j < lines.length && indentOf(lines[j]) > ind) {
100
+ const ci = indentOf(lines[j]);
101
+ let v; [v, i] = isItem(lines[j].trim()) ? parseList(lines, j, ci) : parseMap(lines, j, ci);
102
+ out.push(v);
103
+ } else out.push("");
69
104
  continue;
70
105
  }
71
- out[key] = scalar(rest);
106
+ if (KEY.test(content) && !/^\[.*\]$/.test(content)) {
107
+ // "- key: value" opens a map whose keys sit where this content starts.
108
+ const sub = [" ".repeat(at) + content, ...lines.slice(i)];
109
+ const [v, used] = parseMap(sub, 0, at);
110
+ out.push(v); i += used - 1; continue;
111
+ }
112
+ out.push(inlineOrScalar(content));
72
113
  }
73
- return out;
114
+ return [out, i];
115
+ }
116
+
117
+ function readBlock(lines, i, keyIndent, style) {
118
+ const block = [];
119
+ while (i < lines.length && (lines[i].trim() === "" || indentOf(lines[i]) > keyIndent)) { block.push(lines[i]); i++; }
120
+ while (block.length && !block[block.length - 1].trim()) block.pop();
121
+ const min = Math.min(...block.filter((l) => l.trim()).map(indentOf));
122
+ const stripped = block.map((l) => l.slice(Number.isFinite(min) ? min : 0));
123
+ return [style === "|" ? stripped.join("\n") : foldLines(stripped), i];
124
+ }
125
+
126
+ function inlineOrScalar(rest) {
127
+ if (rest.startsWith("[") && rest.endsWith("]")) return splitInline(rest.slice(1, -1)).map(scalar).filter((s) => s !== "");
128
+ return scalar(rest);
74
129
  }
75
130
 
76
131
  function foldLines(lines) {