infinity-harness 2.0.0 → 2.0.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 (48) hide show
  1. package/CHANGELOG.md +95 -4
  2. package/README.md +30 -3
  3. package/harness/docs/ARCHITECTURE.md +1 -1
  4. package/harness/docs/agents/generator.md +1 -1
  5. package/harness/docs/agents/simplifier.md +1 -1
  6. package/harness/docs/phases/build.md +6 -6
  7. package/harness/docs/phases/define.md +4 -4
  8. package/harness/docs/phases/plan.md +4 -4
  9. package/harness/docs/phases/review.md +5 -5
  10. package/harness/docs/phases/ship.md +5 -5
  11. package/harness/docs/phases/simplify.md +6 -6
  12. package/harness/docs/phases/verify.md +6 -6
  13. package/harness/docs/skills.md +89 -0
  14. package/harness/skills/auth-security.md +1 -0
  15. package/harness/skills/building-tools.md +35 -39
  16. package/harness/skills/capability-acquisition.md +51 -48
  17. package/harness/skills/cli-design.md +3 -3
  18. package/harness/skills/code-review.md +1 -0
  19. package/harness/skills/codebase-design.md +1 -0
  20. package/harness/skills/concurrency-async.md +1 -0
  21. package/harness/skills/config-and-secrets.md +1 -0
  22. package/harness/skills/context-hygiene.md +1 -0
  23. package/harness/skills/databases.md +1 -0
  24. package/harness/skills/diagnosing-bugs.md +1 -0
  25. package/harness/skills/domain-modeling.md +1 -0
  26. package/harness/skills/error-handling-logging.md +1 -0
  27. package/harness/skills/frontend-ui.md +1 -0
  28. package/harness/skills/grilling.md +1 -0
  29. package/harness/skills/http-apis.md +1 -0
  30. package/harness/skills/performance.md +1 -0
  31. package/harness/skills/pi-todo-adapted.md +1 -0
  32. package/harness/skills/planning-tasks.md +1 -0
  33. package/harness/skills/prototype.md +2 -1
  34. package/harness/skills/research.md +1 -0
  35. package/harness/skills/resolving-merge-conflicts.md +2 -1
  36. package/harness/skills/scope-discipline.md +1 -0
  37. package/harness/skills/self-review.md +1 -0
  38. package/harness/skills/stuck-protocol.md +2 -1
  39. package/harness/skills/tdd.md +1 -0
  40. package/harness/skills/testing-infra.md +1 -0
  41. package/harness/skills/writing-skills.md +2 -1
  42. package/package.json +1 -1
  43. package/src/core/brief.ts +57 -2
  44. package/src/core/skills.ts +386 -0
  45. package/src/core/skillsAudit.ts +223 -0
  46. package/src/core/types.ts +2 -0
  47. package/harness/skills/README.md +0 -60
  48. package/harness/skills/building-mcp-servers.md +0 -70
@@ -0,0 +1,386 @@
1
+ /**
2
+ * infinity-harness — the craft skills that ship with the package.
3
+ *
4
+ * 29 short documents on how to do the work well: how to write a test worth
5
+ * keeping, how to debug something intermittent, how to design a module
6
+ * boundary. pi loads them as skills so the model can invoke any of them by
7
+ * name — but a model with 29 skills available and no idea which one applies
8
+ * reads none of them.
9
+ *
10
+ * So the brief names the one or two that match what is being worked on right
11
+ * now. That matching is what this module does. Each skill declares the phases
12
+ * it belongs to and the vocabulary it covers:
13
+ *
14
+ * ---
15
+ * name: concurrency-async
16
+ * description: Concurrency and async correctness — races, idempotency, …
17
+ * tags: [concurrency, async, race, lock, mutex, deadlock, atomic]
18
+ * when: task involves parallel work, background jobs, or shared state
19
+ * phases: [plan, build, verify]
20
+ * ---
21
+ *
22
+ * and a task called "serialise plan writes so two workers can't clobber each
23
+ * other" hits `lock` and `race` in BUILD, so the brief says to read it.
24
+ *
25
+ * The skills live in the package, not the user's project: they travel with the
26
+ * install, and no project needs to vendor them.
27
+ */
28
+
29
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
30
+ import { basename, dirname, join, resolve } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { PHASE_ORDER, type Phase } from "./types.ts";
33
+
34
+ /**
35
+ * What a skill is for, which decides how it earns a place in a brief.
36
+ *
37
+ * - `process` — how to work in a phase. Belonging to the phase is enough:
38
+ * TDD is the right answer for a BUILD task whatever the task says.
39
+ * - `domain` — a subject area. Must share vocabulary with the task; nobody
40
+ * needs the database skill because they happen to be in BUILD.
41
+ * - `meta` — growing the toolkit. Vocabulary only, never a phase.
42
+ */
43
+ export type SkillKind = "process" | "domain" | "meta";
44
+
45
+ export type SkillMeta = {
46
+ /** As pi knows it — `/skill:<name>`. */
47
+ name: string;
48
+ description: string;
49
+ kind: SkillKind;
50
+ /** Vocabulary that should pull this skill in. */
51
+ tags: string[];
52
+ /** Phases it belongs to. Empty means "any phase" — the meta skills. */
53
+ phases: string[];
54
+ /** Prose condition from the header, shown when nothing better is available. */
55
+ when: string;
56
+ file: string;
57
+ };
58
+
59
+ export type SkillMatch = {
60
+ skill: SkillMeta;
61
+ score: number;
62
+ /** Why it surfaced, in words: `build phase · matches "lock", "race"`. */
63
+ why: string;
64
+ };
65
+
66
+ /**
67
+ * Where the shipped skills live.
68
+ *
69
+ * Resolved from this module's own location, so it is right in a checkout
70
+ * (`<repo>/src/core` → `<repo>/harness/skills`) and right inside an install
71
+ * (`node_modules/infinity-harness/src/core` → `…/harness/skills`).
72
+ *
73
+ * pi loads extensions through jiti, which provides `import.meta.url` but not
74
+ * `import.meta.dirname` — hence the long way round.
75
+ */
76
+ export function packagedSkillsDir(): string {
77
+ try {
78
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "harness", "skills");
79
+ } catch {
80
+ return "";
81
+ }
82
+ }
83
+
84
+ /**
85
+ * pi's discovery rules, reproduced:
86
+ * - a directory containing `SKILL.md` is a skill root; that file is the
87
+ * skill and nothing below it is scanned
88
+ * - otherwise direct `.md` children of the root are skills
89
+ * - subdirectories are recursed into, but only to find `SKILL.md`
90
+ * - dotfiles and `node_modules` are skipped
91
+ */
92
+ export function discoverSkillFiles(dir: string, includeRootFiles = true): string[] {
93
+ let entries: string[];
94
+ try {
95
+ entries = readdirSync(dir);
96
+ } catch {
97
+ return [];
98
+ }
99
+
100
+ if (entries.includes("SKILL.md") && isFile(join(dir, "SKILL.md"))) {
101
+ return [join(dir, "SKILL.md")];
102
+ }
103
+
104
+ const found: string[] = [];
105
+ for (const entry of entries.sort()) {
106
+ if (entry.startsWith(".") || entry === "node_modules") continue;
107
+ const full = join(dir, entry);
108
+ if (isDir(full)) {
109
+ found.push(...discoverSkillFiles(full, false));
110
+ continue;
111
+ }
112
+ if (!includeRootFiles || !entry.endsWith(".md") || !isFile(full)) continue;
113
+ found.push(full);
114
+ }
115
+ return found;
116
+ }
117
+
118
+ function isDir(path: string): boolean {
119
+ try {
120
+ return statSync(path).isDirectory();
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function isFile(path: string): boolean {
127
+ try {
128
+ return statSync(path).isFile();
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ // ── Frontmatter ─────────────────────────────────────────────────────────────
135
+
136
+ export type Frontmatter =
137
+ | {
138
+ kind: "ok";
139
+ /** Plain single-line values. */
140
+ scalars: Map<string, string>;
141
+ /** Flow lists: `tags: [a, b]`. */
142
+ lists: Map<string, string[]>;
143
+ /** Keys whose value is a map, a block scalar, or otherwise not plain. */
144
+ structured: Set<string>;
145
+ }
146
+ | { kind: "error"; message: string };
147
+
148
+ const SCALAR_KEYS = new Set(["name", "description", "when", "kind"]);
149
+ const LIST_KEYS = new Set(["tags", "phases"]);
150
+
151
+ /**
152
+ * Read a skill header without a YAML dependency.
153
+ *
154
+ * pi uses a real YAML parser; this reads the subset a skill header is allowed
155
+ * to use, and records anything else as `structured` rather than guessing at
156
+ * it. The audit turns that into a failure, so the two readers can never
157
+ * silently disagree about what a skill is called or what it does.
158
+ *
159
+ * The `startsWith("---")` test is pi's, and it is why a UTF-8 BOM hides an
160
+ * entire header: three invisible bytes and the file has no frontmatter at all.
161
+ */
162
+ export function parseSkillFrontmatter(raw: string): Frontmatter {
163
+ const text = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
164
+ if (!text.startsWith("---")) {
165
+ return {
166
+ kind: "error",
167
+ message: "no frontmatter block (pi requires `---` on the very first line)",
168
+ };
169
+ }
170
+ const end = text.indexOf("\n---", 3);
171
+ if (end === -1) {
172
+ return { kind: "error", message: "frontmatter block is never closed with `---`" };
173
+ }
174
+
175
+ const scalars = new Map<string, string>();
176
+ const lists = new Map<string, string[]>();
177
+ const structured = new Set<string>();
178
+
179
+ for (const line of text.slice(4, end).split("\n")) {
180
+ if (!line.trim() || line.startsWith("#")) continue;
181
+ // An indented line belongs to whatever structured value came before it.
182
+ if (/^\s/.test(line)) continue;
183
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
184
+ if (!match) continue;
185
+ const key = match[1]!;
186
+ const value = match[2]!.trim();
187
+
188
+ if (LIST_KEYS.has(key)) {
189
+ if (value.startsWith("[") && value.endsWith("]")) {
190
+ lists.set(key, splitFlowList(value.slice(1, -1)));
191
+ } else if (value === "") {
192
+ // A block list (`tags:` then ` - a`) is legal YAML but not the house
193
+ // style; record it as structured rather than reading half of it.
194
+ structured.add(key);
195
+ } else {
196
+ lists.set(key, splitFlowList(value));
197
+ }
198
+ continue;
199
+ }
200
+
201
+ if (!SCALAR_KEYS.has(key)) continue;
202
+
203
+ if (value === "" || value === "|" || value === ">" || value.startsWith("[") || value.startsWith("{")) {
204
+ structured.add(key);
205
+ continue;
206
+ }
207
+ scalars.set(key, unquote(value));
208
+ }
209
+
210
+ return { kind: "ok", scalars, lists, structured };
211
+ }
212
+
213
+ function splitFlowList(inner: string): string[] {
214
+ return inner
215
+ .split(",")
216
+ .map((s) => unquote(s.trim()))
217
+ .filter((s) => s.length > 0);
218
+ }
219
+
220
+ function unquote(value: string): string {
221
+ if (value.length >= 2) {
222
+ const first = value[0];
223
+ const last = value[value.length - 1];
224
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
225
+ return value.slice(1, -1);
226
+ }
227
+ }
228
+ // A YAML plain scalar ends at an unquoted ` #`.
229
+ const comment = value.indexOf(" #");
230
+ return (comment === -1 ? value : value.slice(0, comment)).trim();
231
+ }
232
+
233
+ // ── Loading ─────────────────────────────────────────────────────────────────
234
+
235
+ /**
236
+ * Load every skill in a directory. Malformed files are skipped rather than
237
+ * thrown on — a broken skill must never stop a brief from being issued. The
238
+ * audit (`skillsAudit.ts`) is what refuses to let one ship.
239
+ *
240
+ * Deliberately uncached: 29 small files read once per brief costs nothing, and
241
+ * a cache would serve a stale header to anyone editing a skill mid-run.
242
+ */
243
+ export function loadSkills(dir: string = packagedSkillsDir()): SkillMeta[] {
244
+ if (!dir || !existsSync(dir)) return [];
245
+ const out: SkillMeta[] = [];
246
+ for (const file of discoverSkillFiles(dir)) {
247
+ let raw: string;
248
+ try {
249
+ raw = readFileSync(file, "utf-8");
250
+ } catch {
251
+ continue;
252
+ }
253
+ const parsed = parseSkillFrontmatter(raw);
254
+ if (parsed.kind !== "ok") continue;
255
+ const description = parsed.scalars.get("description") ?? "";
256
+ if (!description.trim()) continue;
257
+ const stem = basename(file).replace(/\.md$/, "");
258
+ const name = parsed.scalars.get("name") || (stem === "SKILL" ? basename(dirname(file)) : stem);
259
+ const kind = parsed.scalars.get("kind");
260
+ out.push({
261
+ name,
262
+ description,
263
+ kind: kind === "process" || kind === "domain" || kind === "meta" ? kind : "domain",
264
+ tags: parsed.lists.get("tags") ?? [],
265
+ phases: parsed.lists.get("phases") ?? [],
266
+ when: parsed.scalars.get("when") ?? "",
267
+ file,
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ // ── Matching ────────────────────────────────────────────────────────────────
274
+
275
+ const PHASE_WEIGHT = 4;
276
+ const TAG_WEIGHT = 2;
277
+ const NAME_WEIGHT = 3;
278
+ /** Three tag hits already means "yes, this one"; more shouldn't drown a phase. */
279
+ const MAX_TAG_HITS = 3;
280
+ /**
281
+ * A single incidental tag hit is not a recommendation. "package-lock.json"
282
+ * contains `json`, which is on the HTTP skill's tag list, and nobody resolving
283
+ * a merge conflict needs to read about REST.
284
+ */
285
+ const MIN_SCORE = 3;
286
+
287
+ export type MatchOptions = {
288
+ phase?: Phase | null;
289
+ /** Everything known about the current work: task, feature, goal, criteria. */
290
+ text?: string;
291
+ limit?: number;
292
+ };
293
+
294
+ /**
295
+ * Rank skills against the work in hand.
296
+ *
297
+ * A skill earns its place by belonging to this phase, by sharing vocabulary
298
+ * with the task, or both — but only `process` skills can qualify on the phase
299
+ * alone. That distinction is the whole difference between a useful section and
300
+ * a section people learn to skip: without it, every BUILD task in the world
301
+ * gets told to read about authentication, because `auth-security` lists BUILD
302
+ * among its phases and sorts early in the alphabet.
303
+ *
304
+ * Where a phase appears in a skill's list matters too. `diagnosing-bugs`
305
+ * declares `[verify, build]` — it is a VERIFY skill that is also useful in
306
+ * BUILD — so it should not outrank `tdd`, which leads with BUILD, on a BUILD
307
+ * task that says nothing about bugs.
308
+ *
309
+ * Skills that score nothing are left out entirely. An empty section is honest;
310
+ * a padded one is noise.
311
+ */
312
+ export function matchSkills(skills: SkillMeta[], options: MatchOptions = {}): SkillMatch[] {
313
+ const { phase = null, text = "", limit = 3 } = options;
314
+ const haystack = text.toLowerCase();
315
+ const tokens = new Set(haystack.split(/[^a-z0-9]+/).filter(Boolean));
316
+
317
+ const matches: SkillMatch[] = [];
318
+ for (const skill of skills) {
319
+ let score = 0;
320
+ const reasons: string[] = [];
321
+
322
+ const hits: string[] = [];
323
+ for (const tag of skill.tags) {
324
+ const t = tag.toLowerCase();
325
+ // Hyphenated tags don't survive tokenisation, so look for them whole.
326
+ // Everything else matches its own plural: a task about "two workers"
327
+ // is about the `worker` tag, and pretending otherwise loses the match
328
+ // that mattered.
329
+ const hit = t.includes("-") ? haystack.includes(t) : hasWord(tokens, t);
330
+ if (hit && !hits.includes(t)) hits.push(t);
331
+ }
332
+
333
+ const position = phase ? skill.phases.indexOf(phase) : -1;
334
+ const phaseCounts = position >= 0 && (skill.kind === "process" || hits.length > 0);
335
+ if (phaseCounts) {
336
+ score += Math.max(1, PHASE_WEIGHT - position);
337
+ reasons.push(`${phase} phase`);
338
+ }
339
+
340
+ if (hits.length && skill.kind !== "meta") {
341
+ score += TAG_WEIGHT * Math.min(hits.length, MAX_TAG_HITS);
342
+ } else if (hits.length) {
343
+ // A meta skill has to be asked for: `meta` is on all of their tag lists,
344
+ // so counting it like any other hit would surface all four constantly.
345
+ const real = hits.filter((h) => h !== "meta");
346
+ if (real.length === 0) continue;
347
+ score += TAG_WEIGHT * Math.min(real.length, MAX_TAG_HITS);
348
+ }
349
+ if (hits.length) {
350
+ const shown = hits.filter((h) => h !== "meta" || skill.kind !== "meta").slice(0, MAX_TAG_HITS);
351
+ if (shown.length) reasons.push(`matches ${shown.map((h) => `"${h}"`).join(", ")}`);
352
+ }
353
+
354
+ // Naming the skill in the task means it, and outranks any guess.
355
+ if (skill.name && haystack.includes(skill.name.toLowerCase())) {
356
+ score += NAME_WEIGHT;
357
+ }
358
+
359
+ if (score < MIN_SCORE) continue;
360
+ matches.push({ skill, score, why: reasons.join(" · ") });
361
+ }
362
+
363
+ // Ties break by phase specificity then name, so the same state always
364
+ // produces the same brief — one that reshuffles between turns reads as new
365
+ // information when nothing has changed.
366
+ matches.sort(
367
+ (a, b) =>
368
+ b.score - a.score ||
369
+ a.skill.phases.length - b.skill.phases.length ||
370
+ a.skill.name.localeCompare(b.skill.name),
371
+ );
372
+ return matches.slice(0, Math.max(0, limit));
373
+ }
374
+
375
+ /** Token match, tolerant of a trailing plural on either side. */
376
+ function hasWord(tokens: Set<string>, tag: string): boolean {
377
+ if (tokens.has(tag)) return true;
378
+ if (tokens.has(`${tag}s`)) return true;
379
+ return tag.endsWith("s") && tokens.has(tag.slice(0, -1));
380
+ }
381
+
382
+ /** Every kind a skill header may legally declare. */
383
+ export const SKILL_KINDS: readonly string[] = ["process", "domain", "meta"];
384
+
385
+ /** Every phase name a skill header may legally declare. */
386
+ export const KNOWN_PHASES: readonly string[] = PHASE_ORDER;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * infinity-harness — skill file audit.
3
+ *
4
+ * pi loads **every** `.md` file in a declared skills directory as a skill and
5
+ * validates it. A file that fails validation is not quietly ignored: pi prints
6
+ * a `[Skill conflicts]` block on every single start, naming the file and the
7
+ * problem. A stray `README.md` beside the skills is enough to do it — that is
8
+ * exactly how this module came to exist.
9
+ *
10
+ * The point is to turn a warning the user sees at runtime into a failure we
11
+ * see at test time. This re-implements pi's validation rules (`core/skills.js`
12
+ * in the agent) closely enough that a clean audit means a clean start, over
13
+ * the discovery and header parsing in `skills.ts`.
14
+ *
15
+ * Two deliberate differences, both in the stricter direction — this must never
16
+ * pass something pi would reject:
17
+ *
18
+ * - pi honours `.gitignore` / `.ignore` / `.fdignore` inside the skills tree
19
+ * and skips what they exclude. We audit everything we find.
20
+ * - pi parses frontmatter with a real YAML parser. We accept only plain
21
+ * scalars and flow lists, and report anything else rather than guessing.
22
+ * Frontmatter clever enough to need a YAML parser is frontmatter nobody
23
+ * should be writing in a skill header.
24
+ */
25
+
26
+ import { readFileSync } from "node:fs";
27
+ import { basename, dirname } from "node:path";
28
+ import { KNOWN_PHASES, SKILL_KINDS, discoverSkillFiles, parseSkillFrontmatter } from "./skills.ts";
29
+
30
+ /** pi: MAX_NAME_LENGTH. */
31
+ export const MAX_NAME_LENGTH = 64;
32
+ /** pi: MAX_DESCRIPTION_LENGTH. */
33
+ export const MAX_DESCRIPTION_LENGTH = 1024;
34
+ /** pi: validateName. */
35
+ export const NAME_RE = /^[a-z0-9-]+$/;
36
+
37
+ export type SkillProblem = {
38
+ /** Absolute path of the offending file. */
39
+ file: string;
40
+ /** Phrased the way pi phrases it, where pi has a phrasing. */
41
+ message: string;
42
+ };
43
+
44
+ export type SkillEntry = {
45
+ file: string;
46
+ name: string;
47
+ description: string;
48
+ };
49
+
50
+ export type SkillAudit = {
51
+ /** Files that would load as skills. */
52
+ skills: SkillEntry[];
53
+ /** Everything wrong, in discovery order. */
54
+ problems: SkillProblem[];
55
+ };
56
+
57
+ /**
58
+ * Walk a skills directory the way pi does and report what it would say.
59
+ *
60
+ * A missing directory is not a problem — pi treats it as "no skills here", and
61
+ * so do we; a package may legitimately declare a directory it does not ship.
62
+ */
63
+ export function auditSkillsDir(dir: string): SkillAudit {
64
+ const audit: SkillAudit = { skills: [], problems: [] };
65
+
66
+ for (const file of discoverSkillFiles(dir)) {
67
+ inspect(file, audit);
68
+ }
69
+
70
+ // pi keys skills by name, so two files claiming one name means one of them
71
+ // simply does not exist — and nothing warns about that, which makes it worse
72
+ // than the errors that do.
73
+ const byName = new Map<string, string[]>();
74
+ for (const skill of audit.skills) {
75
+ const seen = byName.get(skill.name);
76
+ if (seen) seen.push(skill.file);
77
+ else byName.set(skill.name, [skill.file]);
78
+ }
79
+ for (const [name, files] of byName) {
80
+ if (files.length < 2) continue;
81
+ for (const file of files.slice(1)) {
82
+ audit.problems.push({
83
+ file,
84
+ message: `duplicate skill name "${name}" (also declared by ${files[0]})`,
85
+ });
86
+ }
87
+ }
88
+
89
+ return audit;
90
+ }
91
+
92
+ /** Validate one file, appending to the audit. */
93
+ function inspect(file: string, audit: SkillAudit): void {
94
+ let raw: string;
95
+ try {
96
+ raw = readFileSync(file, "utf-8");
97
+ } catch (e) {
98
+ audit.problems.push({ file, message: e instanceof Error ? e.message : "unreadable" });
99
+ return;
100
+ }
101
+
102
+ // pi tests `content.startsWith("---")` against the raw string, so a UTF-8
103
+ // BOM hides the frontmatter completely and the file reads as a skill with no
104
+ // description at all — a confusing way to learn your editor added three
105
+ // invisible bytes.
106
+ if (raw.charCodeAt(0) === 0xfeff) {
107
+ audit.problems.push({
108
+ file,
109
+ message: "starts with a UTF-8 BOM, which hides the frontmatter from pi",
110
+ });
111
+ return;
112
+ }
113
+
114
+ const parsed = parseSkillFrontmatter(raw);
115
+ if (parsed.kind === "error") {
116
+ audit.problems.push({ file, message: parsed.message });
117
+ return;
118
+ }
119
+
120
+ for (const key of ["name", "description"]) {
121
+ if (parsed.structured.has(key)) {
122
+ audit.problems.push({ file, message: `\`${key}\` must be a plain single-line value` });
123
+ }
124
+ }
125
+ if (parsed.structured.has("name") || parsed.structured.has("description")) return;
126
+
127
+ const declaredName = parsed.scalars.get("name") ?? "";
128
+ const description = parsed.scalars.get("description") ?? "";
129
+ const stem = basename(file).replace(/\.md$/, "");
130
+ const isSkillRoot = stem === "SKILL";
131
+ // pi's fallback when frontmatter omits a name: the parent directory name.
132
+ const name = declaredName || basename(dirname(file));
133
+ let usable = true;
134
+
135
+ if (description.trim() === "") {
136
+ // pi's exact wording, so a search for the message people actually see
137
+ // lands here.
138
+ audit.problems.push({ file, message: "description is required" });
139
+ usable = false;
140
+ } else if (description.length > MAX_DESCRIPTION_LENGTH) {
141
+ audit.problems.push({
142
+ file,
143
+ message: `description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`,
144
+ });
145
+ }
146
+
147
+ for (const message of nameProblems(name)) {
148
+ audit.problems.push({ file, message });
149
+ }
150
+
151
+ // House rules pi does not enforce, both of which produce a skill that loads
152
+ // and then never does anything.
153
+ if (!declaredName) {
154
+ audit.problems.push({
155
+ file,
156
+ message: "name is required (pi would fall back to the directory name)",
157
+ });
158
+ } else if (!isSkillRoot && declaredName !== stem) {
159
+ audit.problems.push({
160
+ file,
161
+ message: `name "${declaredName}" does not match filename "${stem}.md"`,
162
+ });
163
+ }
164
+
165
+ // The brief routes on `kind`, and a skill with no kind is silently filed as
166
+ // `domain` — which means a process skill without one stops being offered for
167
+ // its phase and nobody finds out.
168
+ const kind = parsed.scalars.get("kind") ?? "";
169
+ if (!kind) {
170
+ audit.problems.push({ file, message: `kind is required (one of ${SKILL_KINDS.join(", ")})` });
171
+ } else if (!SKILL_KINDS.includes(kind)) {
172
+ audit.problems.push({
173
+ file,
174
+ message: `unknown kind "${kind}" (expected one of ${SKILL_KINDS.join(", ")})`,
175
+ });
176
+ }
177
+
178
+ // A typo in `phases:` is invisible: the skill loads, and the brief never
179
+ // offers it, because no phase is ever called `verfiy`.
180
+ for (const phase of parsed.lists.get("phases") ?? []) {
181
+ if (!KNOWN_PHASES.includes(phase)) {
182
+ audit.problems.push({
183
+ file,
184
+ message: `unknown phase "${phase}" (expected one of ${KNOWN_PHASES.join(", ")})`,
185
+ });
186
+ }
187
+ }
188
+ if (parsed.structured.has("phases") || parsed.structured.has("tags")) {
189
+ audit.problems.push({
190
+ file,
191
+ message: "`tags` and `phases` must be inline lists, e.g. `tags: [a, b]`",
192
+ });
193
+ }
194
+
195
+ if (usable) audit.skills.push({ file, name: declaredName || name, description });
196
+ }
197
+
198
+ /** pi: validateName. */
199
+ export function nameProblems(name: string): string[] {
200
+ const problems: string[] = [];
201
+ if (name.length > MAX_NAME_LENGTH) {
202
+ problems.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
203
+ }
204
+ if (!NAME_RE.test(name)) {
205
+ problems.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)");
206
+ }
207
+ if (name.startsWith("-") || name.endsWith("-")) {
208
+ problems.push("name must not start or end with a hyphen");
209
+ }
210
+ if (name.includes("--")) {
211
+ problems.push("name must not contain consecutive hyphens");
212
+ }
213
+ return problems;
214
+ }
215
+
216
+ /** One line per problem, for a test failure message or a CLI. */
217
+ export function formatAudit(audit: SkillAudit, root?: string): string {
218
+ if (audit.problems.length === 0) {
219
+ return `${audit.skills.length} skills, no problems`;
220
+ }
221
+ const rel = (file: string) => (root && file.startsWith(root) ? file.slice(root.length + 1) : file);
222
+ return audit.problems.map((p) => `${rel(p.file)}: ${p.message}`).join("\n");
223
+ }
package/src/core/types.ts CHANGED
@@ -223,6 +223,8 @@ export type Brief = {
223
223
  gate: GateResult | null;
224
224
  progress: { tasksDone: number; tasksTotal: number; featuresDone: number; featuresTotal: number };
225
225
  retries: { task: number; feature: number; phase: number; max: number };
226
+ /** Craft skills worth reading before starting this task, best match first. */
227
+ skills: { name: string; description: string; why: string }[];
226
228
  notes: string[];
227
229
  };
228
230
 
@@ -1,60 +0,0 @@
1
- # Craft Skills
2
-
3
- How to do the work WELL — the engineering discipline behind each pipeline
4
- phase. The phase docs (`harness/docs/phases/`) say *what* to produce; these
5
- skills say *how* an expert produces it.
6
-
7
- `the infinity_brief tool` matches skills to your current task and points you at
8
- the right ones. Read the referenced skill BEFORE working — it is short and
9
- it will change what you do. Find skills yourself:
10
- `infinity-harness capability match "<your task>"`.
11
-
12
- ## Process skills (phase-mapped)
13
-
14
- | Skill | Use during | One-liner |
15
- |-------|-----------|-----------|
16
- | `grilling.md` | DEFINE | Stress-test the spec with relentless questions |
17
- | `domain-modeling.md` | DEFINE | Pin down domain terms before writing code |
18
- | `research.md` | DEFINE, anytime | Answer questions from primary sources only |
19
- | `planning-tasks.md` | PLAN | Break specs into tracer-bullet vertical slices |
20
- | `codebase-design.md` | PLAN, SIMPLIFY | Design deep modules behind small interfaces |
21
- | `tdd.md` | BUILD | Red → green loop; tests worth keeping |
22
- | `prototype.md` | BUILD | Throwaway code that answers a design question |
23
- | `diagnosing-bugs.md` | VERIFY, anytime | Build a feedback loop before hypothesizing |
24
- | `code-review.md` | REVIEW | Two-axis review: standards + spec |
25
- | `resolving-merge-conflicts.md` | anytime | Resolve conflicts by original intent |
26
-
27
- ## Domain skills (task-matched by tags)
28
-
29
- `databases` · `http-apis` · `auth-security` · `frontend-ui` ·
30
- `testing-infra` · `concurrency-async` · `performance` ·
31
- `error-handling-logging` · `config-and-secrets` · `cli-design`
32
-
33
- ## Frontier playbook (how a strong model operates)
34
-
35
- | Skill | Delivery surface |
36
- |-------|-----------------|
37
- | `self-review.md` | Its pass runs before EVERY validate (briefs remind you) |
38
- | `stuck-protocol.md` | Referenced when retries fail — stop thrashing, escalate cleanly |
39
- | `context-hygiene.md` | Externalize discoveries the moment they happen |
40
- | `scope-discipline.md` | The contract is the boundary; park everything else |
41
-
42
- ## The capability ladder (meta-skills)
43
-
44
- | Skill | Purpose |
45
- |-------|---------|
46
- | `capability-acquisition.md` | HAVE → ACQUIRE → CREATE → KEEP, for skills/MCP/tools |
47
- | `writing-skills.md` | How to author a skill worth keeping |
48
- | `building-mcp-servers.md` | Scaffold, fill handlers, self-test, register |
49
- | `building-tools.md` | Project tool standards + registration |
50
-
51
- Growing the library IS part of the job: acquired and created capabilities
52
- are registered (`infinity-harness capability add ...`) so the next task starts
53
- ahead. Export your accumulated skills across projects:
54
- `infinity-harness capability export`.
55
-
56
- ## Attribution
57
-
58
- Skills marked "Adapted from mattpocock/skills" derive from
59
- [Matt Pocock's skills repository](https://github.com/mattpocock/skills)
60
- (MIT License, © 2026 Matt Pocock), adapted for the infinity-harness pipeline.