infinity-harness 2.0.0 → 2.0.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 +59 -0
- package/README.md +30 -3
- package/harness/docs/ARCHITECTURE.md +1 -1
- package/harness/docs/skills.md +89 -0
- package/harness/skills/auth-security.md +1 -0
- package/harness/skills/building-tools.md +35 -39
- package/harness/skills/capability-acquisition.md +1 -0
- package/harness/skills/cli-design.md +1 -0
- package/harness/skills/code-review.md +1 -0
- package/harness/skills/codebase-design.md +1 -0
- package/harness/skills/concurrency-async.md +1 -0
- package/harness/skills/config-and-secrets.md +1 -0
- package/harness/skills/context-hygiene.md +1 -0
- package/harness/skills/databases.md +1 -0
- package/harness/skills/diagnosing-bugs.md +1 -0
- package/harness/skills/domain-modeling.md +1 -0
- package/harness/skills/error-handling-logging.md +1 -0
- package/harness/skills/frontend-ui.md +1 -0
- package/harness/skills/grilling.md +1 -0
- package/harness/skills/http-apis.md +1 -0
- package/harness/skills/performance.md +1 -0
- package/harness/skills/pi-todo-adapted.md +1 -0
- package/harness/skills/planning-tasks.md +1 -0
- package/harness/skills/prototype.md +2 -1
- package/harness/skills/research.md +1 -0
- package/harness/skills/resolving-merge-conflicts.md +1 -0
- package/harness/skills/scope-discipline.md +1 -0
- package/harness/skills/self-review.md +1 -0
- package/harness/skills/stuck-protocol.md +1 -0
- package/harness/skills/tdd.md +1 -0
- package/harness/skills/testing-infra.md +1 -0
- package/harness/skills/writing-skills.md +2 -1
- package/package.json +1 -1
- package/src/core/brief.ts +45 -0
- package/src/core/skills.ts +386 -0
- package/src/core/skillsAudit.ts +223 -0
- package/src/core/types.ts +2 -0
- package/harness/skills/README.md +0 -60
- package/harness/skills/building-mcp-servers.md +0 -70
package/src/core/brief.ts
CHANGED
|
@@ -22,6 +22,13 @@ import { runChecks } from "./gates.ts";
|
|
|
22
22
|
import { getPhaseOrder, nextPhase, isFinalPhase } from "./phases.ts";
|
|
23
23
|
import * as P from "./paths.ts";
|
|
24
24
|
import { readText } from "./fsx.ts";
|
|
25
|
+
import { loadSkills, matchSkills } from "./skills.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Two is the number that gets read. One hides a better match; five is a
|
|
29
|
+
* reading list, and a reading list is a thing you skip.
|
|
30
|
+
*/
|
|
31
|
+
const SKILL_SUGGESTIONS = 2;
|
|
25
32
|
|
|
26
33
|
const PHASE_INTENT: Record<Phase, string> = {
|
|
27
34
|
init: "Set up the project skeleton and confirm the harness can see it.",
|
|
@@ -102,10 +109,39 @@ export async function buildBrief(targetDir: string, options: BuildBriefOptions =
|
|
|
102
109
|
phase: config.phaseRetryCount ?? 0,
|
|
103
110
|
max: retry.tasks.max,
|
|
104
111
|
},
|
|
112
|
+
skills: suggestSkills(phase, {
|
|
113
|
+
goal: (list.goals ?? [])[0]?.title ?? null,
|
|
114
|
+
feature: feature?.name ?? null,
|
|
115
|
+
task: nextTask?.description ?? null,
|
|
116
|
+
criteria: collectCriteria(feature, nextTask?.criteria),
|
|
117
|
+
}),
|
|
105
118
|
notes,
|
|
106
119
|
};
|
|
107
120
|
}
|
|
108
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Which craft skills to point at, given what is being worked on.
|
|
124
|
+
*
|
|
125
|
+
* The skills ship with the package, so this works in an install with no
|
|
126
|
+
* project-level setup. If they are missing — someone vendored `src/` alone —
|
|
127
|
+
* the brief simply has no SKILLS section rather than failing to build.
|
|
128
|
+
*/
|
|
129
|
+
function suggestSkills(
|
|
130
|
+
phase: Phase | null,
|
|
131
|
+
work: { goal: string | null; feature: string | null; task: string | null; criteria: string[] },
|
|
132
|
+
): Brief["skills"] {
|
|
133
|
+
const text = [work.task, work.feature, work.goal, ...work.criteria].filter(Boolean).join(" \n ");
|
|
134
|
+
try {
|
|
135
|
+
return matchSkills(loadSkills(), { phase, text, limit: SKILL_SUGGESTIONS }).map((m) => ({
|
|
136
|
+
name: m.skill.name,
|
|
137
|
+
description: m.skill.description,
|
|
138
|
+
why: m.why,
|
|
139
|
+
}));
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
function collectCriteria(
|
|
110
146
|
feature: { criteria?: string[] } | null,
|
|
111
147
|
taskCriteria: string[] | undefined,
|
|
@@ -189,6 +225,15 @@ export function renderBrief(brief: Brief, config?: HarnessConfig): string {
|
|
|
189
225
|
for (const c of brief.criteria) L.push(` - ${c}`);
|
|
190
226
|
}
|
|
191
227
|
|
|
228
|
+
if (brief.skills.length) {
|
|
229
|
+
L.push("");
|
|
230
|
+
L.push("SKILLS read before you start — invoke by name");
|
|
231
|
+
for (const s of brief.skills) {
|
|
232
|
+
L.push(` - ${s.name} — ${s.description}`);
|
|
233
|
+
if (s.why) L.push(` (${s.why})`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
192
237
|
if (brief.gate) {
|
|
193
238
|
L.push("");
|
|
194
239
|
L.push(`GATE ${brief.gate.overall ? "PASS" : "FAIL"}`);
|
|
@@ -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
|
|