@jaybeeuu/agent-cortex 1.43.0 → 2.1.0

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 (58) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +5 -6
  2. package/.github/workflows/ci.yml +4 -11
  3. package/AGENTS.md +13 -17
  4. package/CHANGELOG.md +66 -0
  5. package/README.md +57 -57
  6. package/agents/README.md +9 -23
  7. package/agents/ralph/agent.md +13 -17
  8. package/agents/ralph/claude/polling.md +1 -1
  9. package/agents-native/ralph.md +15 -5
  10. package/bin/agent-cortex.mjs +0 -16
  11. package/bin/installers/claude.mjs +82 -11
  12. package/docs/claude-hooks.md +16 -0
  13. package/docs/ideas/improve-pi-system-prompt.md +9 -3
  14. package/docs/research/disable-model-invocation.md +5 -26
  15. package/docs/skills/skill-anatomy.md +5 -3
  16. package/docs/technical-direction/harness-config.md +6 -14
  17. package/docs/user-preferences.md +2 -2
  18. package/extensions/agent-modes/README.md +3 -3
  19. package/extensions/agent-modes/discover.ts +6 -6
  20. package/extensions/output-style/README.md +58 -0
  21. package/extensions/output-style/index.ts +23 -0
  22. package/extensions/output-style/output-style.md +43 -0
  23. package/extensions/output-style/output-style.test.ts +50 -0
  24. package/extensions/output-style/output-style.ts +32 -0
  25. package/extensions/output-style/package.json +19 -0
  26. package/extensions/output-style/tsconfig.json +12 -0
  27. package/extensions/subagent/README.md +2 -2
  28. package/extensions/subagent/agents.ts +1 -2
  29. package/lib/cli.mjs +5 -7
  30. package/package.json +5 -6
  31. package/scripts/check-skill-frontmatter.mjs +21 -0
  32. package/scripts/lib/compose-agent.mjs +10 -10
  33. package/scripts/lib/skill-frontmatter.mjs +135 -0
  34. package/skills/workflow/ralph/REFERENCE.md +34 -11
  35. package/skills/workflow/ralph/SKILL.md +10 -10
  36. package/skills/workflow/ralph/scripts/pending-gates.mjs +134 -0
  37. package/test/cli.test.mjs +11 -10
  38. package/test/installer.test.mjs +143 -1
  39. package/test/pending-gates.test.mjs +147 -0
  40. package/test/skill-frontmatter.test.mjs +137 -0
  41. package/token-map.README.md +9 -12
  42. package/token-map.json +26 -31
  43. package/agents/plan/copilot/frontmatter.json +0 -6
  44. package/agents/plan.agent.md +0 -59
  45. package/agents/ralph/copilot/frontmatter.json +0 -6
  46. package/agents/ralph/copilot/polling.md +0 -62
  47. package/agents/ralph-plan/copilot/frontmatter.json +0 -6
  48. package/agents/ralph-plan.agent.md +0 -126
  49. package/agents/ralph.agent.md +0 -331
  50. package/agents/strategy/copilot/frontmatter.json +0 -6
  51. package/agents/strategy.agent.md +0 -112
  52. package/bin/installers/copilot.mjs +0 -124
  53. package/hooks.json +0 -10
  54. package/plugin.json +0 -35
  55. package/scripts/build-copilot-agents.mjs +0 -30
  56. package/scripts/install-instructions.sh +0 -82
  57. package/scripts/sync-plugin-version.sh +0 -17
  58. package/test/copilot-installer.test.mjs +0 -308
@@ -18,9 +18,9 @@ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
18
18
  * and symlinked installs).
19
19
  *
20
20
  * Flat `agents/*.agent.md` files are parsed as a fallback for agents that have
21
- * no composable directory (they are generated output for Copilot, so they may
22
- * exist for any agent). A composable directory always wins over its flat
23
- * counterpart.
21
+ * no composable directory (a staged pi package ships flat renders, and a
22
+ * hand-written agent may be a single flat file). A composable directory always
23
+ * wins over its flat counterpart.
24
24
  */
25
25
 
26
26
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -40,7 +40,7 @@ export interface AgentDef {
40
40
  prompt: string;
41
41
  }
42
42
 
43
- export type HarnessId = "copilot" | "claude" | "pi";
43
+ export type HarnessId = "claude" | "pi";
44
44
 
45
45
  export interface PathSpec {
46
46
  base: string;
@@ -431,8 +431,8 @@ function readFlatAgent(filePath: string, tokenMap: TokenMap | null): AgentDef |
431
431
  * Discover agents from the agents directory.
432
432
  *
433
433
  * Composable directories (`<name>/agent.md` + `<name>/pi/frontmatter.json`) are
434
- * the primary source. Flat `*.agent.md` files (generated output for Copilot) are
435
- * read only when no composable directory exists for that agent id. When
434
+ * the primary source. Flat `*.agent.md` files (a staged package's renders, or a
435
+ * hand-written agent) are read only when no composable directory exists for that agent id. When
436
436
  * `tokenMap` is omitted, token-map.json is loaded from the package root;
437
437
  * missing/unreadable maps fall back to the built-in copilot→pi translation.
438
438
  */
@@ -0,0 +1,58 @@
1
+ # Output Style
2
+
3
+ Appends the always-on reply shape to the PI system prompt once per user prompt.
4
+
5
+ This is the second slice of `docs/ideas/improve-pi-system-prompt.md`: the first
6
+ (`agent-guardrails`) stops wasted work; this one stops the answer getting buried in
7
+ it. The extension injects a stable block via the `before_agent_start` hook — which
8
+ fires once per user prompt, before the agent loop — so the rules apply to every
9
+ turn of the run without rewriting Pi's default prompt.
10
+
11
+ ## Rules
12
+
13
+ - **Lead with the action** — the first line is the answer, command, path, or next
14
+ action; multi-step work is numbered; the reply ends with one next step.
15
+ - **Keep the prose short** — no preamble, no recap, no closing offer; five items
16
+ per list at most; one topic per reply.
17
+ - **Write Simplified Technical English (ASD-STE100)** — active voice, 20-word
18
+ sentences for instructions and 25-word sentences for description, verb over
19
+ noun, one word one meaning, literal words, no marketing adjectives, articles
20
+ kept.
21
+ - **Restate the context** — never cite a bead, ticket, phase, or stage by ID alone;
22
+ say what it is in plain words first, and restate state after a gap or topic
23
+ switch.
24
+
25
+ The rules shape the reply, never the work. When a rule would delete the answer,
26
+ the task wins.
27
+
28
+ ## Structure
29
+
30
+ ```
31
+ output-style/
32
+ ├── README.md # This file
33
+ ├── index.ts # Extension entry point — appends on before_agent_start
34
+ ├── output-style.ts # Loads the block + appendOutputStyle composition
35
+ ├── output-style.md # The block text (single source, also shipped to Claude)
36
+ ├── output-style.test.ts # Composition tests
37
+ ├── package.json
38
+ └── tsconfig.json
39
+ ```
40
+
41
+ ## Testing
42
+
43
+ ```bash
44
+ cd extensions/output-style
45
+ node --test --import tsx/esm output-style.test.ts
46
+ ```
47
+
48
+ ## Design notes
49
+
50
+ - The block is a stable constant appended after any chained system-prompt changes,
51
+ so the prompt-cache prefix is preserved.
52
+ - `appendOutputStyle` is idempotent — a prompt that already carries the marker is
53
+ returned unchanged, so a duplicate extension load cannot double the rules.
54
+ - Separate from `agent-guardrails` because it is a different failure: guardrails
55
+ govern *whether* work continues, this governs *how the result is reported*.
56
+ - The prose lives in `output-style.md`, not in the TypeScript, because there is a
57
+ second consumer: `agent-cortex install claude` wraps that file in the managed
58
+ `~/.claude/CLAUDE.md` section. One file, two harnesses, no drift.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Output Style Extension
3
+ *
4
+ * Appends the always-on reply shape to the PI system prompt on every turn: lead
5
+ * with the action, write in Simplified Technical English (ASD-STE100), restate the
6
+ * context the reader cannot remember (the second slice of
7
+ * docs/ideas/improve-pi-system-prompt.md).
8
+ *
9
+ * The block is stable text appended after any chained system-prompt changes, so it
10
+ * keeps the prompt-cache prefix intact while remaining authoritative for the model.
11
+ * See output-style.ts for the rules and appendOutputStyle for composition.
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { appendOutputStyle } from "./output-style.ts";
16
+
17
+ export { OUTPUT_STYLE, OUTPUT_STYLE_MARKER, appendOutputStyle } from "./output-style.ts";
18
+
19
+ export default function outputStyleExtension(pi: ExtensionAPI): void {
20
+ pi.on("before_agent_start", (event) => {
21
+ return { systemPrompt: appendOutputStyle(event.systemPrompt) };
22
+ });
23
+ }
@@ -0,0 +1,43 @@
1
+ ## Output style (agent-cortex)
2
+
3
+ How every reply is written. These rules shape the reply, never the work: do all
4
+ the analysis and tool calls the task needs, then report the result this way. If a
5
+ rule would delete the answer itself, the task wins; the shape stays.
6
+
7
+ ### Lead with the action
8
+
9
+ - The first line is the answer, the command, the path, or the next action. Not
10
+ context, not a plan, not "Let me…".
11
+ - Number multi-step work. One bounded action per step.
12
+ - End with one concrete next step. Omit it when the work is finished.
13
+
14
+ ### Keep the prose short
15
+
16
+ - No preamble ("Great question", "I'll now…"), no recap of work just completed,
17
+ no closing offer ("Let me know if…", "Hope this helps").
18
+ - Five items maximum per list. Rank them; group related items.
19
+ - One topic per reply. Name a tangent in one line at the end, then stop.
20
+
21
+ ### Write Simplified Technical English (ASD-STE100)
22
+
23
+ - Active voice. Imperative for instructions.
24
+ - Sentence limit: 20 words for instructions, 25 for descriptive text. One
25
+ instruction per sentence.
26
+ - Use the verb, not the noun: "check the file", not "perform a check of the file".
27
+ - One word, one meaning: pick the term for a thing, then reuse it.
28
+ - Literal words only. No idioms, metaphors, or figures of speech ("circle back",
29
+ "under the hood", "ballpark").
30
+ - No marketing adjectives or filler ("seamless", "robust", "comprehensive").
31
+ - Keep articles and subjects. Do not compress into fragments that lose meaning.
32
+ - Give times in concrete units ("about 10 minutes"), never "a bit of work".
33
+
34
+ ### Restate the context, never assume it
35
+
36
+ The reader cannot hold earlier turns in working memory.
37
+
38
+ - Never name a bead, ticket, phase, or pipeline stage by ID or number alone. Say
39
+ what it is in plain words first — "the login fix, verify step (bd-142)" — and
40
+ give the ID at most once.
41
+ - After a gap, a topic switch, or a long tool run, restate in one line: what is
42
+ done, what is next.
43
+ - Mark progress concretely: "Step 2 of 4 done: schema updated."
@@ -0,0 +1,50 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { OUTPUT_STYLE, OUTPUT_STYLE_MARKER, appendOutputStyle } from "./output-style.ts";
5
+
6
+ // ─── appendOutputStyle ────────────────────────────────────────────────────────
7
+
8
+ describe("appendOutputStyle", () => {
9
+ it("appends the output-style block after the base prompt", () => {
10
+ const result = appendOutputStyle("You are a helpful agent.");
11
+
12
+ assert.ok(result.startsWith("You are a helpful agent."));
13
+ assert.ok(result.includes(OUTPUT_STYLE));
14
+ });
15
+
16
+ it("preserves a chained base prompt from earlier handlers", () => {
17
+ const base = "Base prompt.\n\n---\nAgent mode instructions.";
18
+ const result = appendOutputStyle(base);
19
+
20
+ assert.ok(result.startsWith(base));
21
+ });
22
+
23
+ it("trims trailing whitespace before appending", () => {
24
+ const result = appendOutputStyle("Base prompt.\n\n");
25
+
26
+ assert.equal(result, `Base prompt.\n\n${OUTPUT_STYLE}`);
27
+ });
28
+
29
+ it("keeps a block appended by another extension", () => {
30
+ const withGuardrails = "Base prompt.\n\n## Operating discipline (agent-cortex)\n\nRules.\n";
31
+ const result = appendOutputStyle(withGuardrails);
32
+
33
+ assert.ok(result.includes("## Operating discipline (agent-cortex)"));
34
+ assert.ok(result.endsWith(OUTPUT_STYLE));
35
+ });
36
+
37
+ it("is idempotent when applied twice", () => {
38
+ const once = appendOutputStyle("Base prompt.");
39
+ const twice = appendOutputStyle(once);
40
+
41
+ assert.equal(twice, once);
42
+ assert.equal(twice.split(OUTPUT_STYLE_MARKER).length - 1, 1);
43
+ });
44
+
45
+ it("returns the prompt unchanged when the output style is already present", () => {
46
+ const existing = `Base prompt.\n\n${OUTPUT_STYLE}`;
47
+
48
+ assert.equal(appendOutputStyle(existing), existing);
49
+ });
50
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Output style for the PI system prompt.
3
+ *
4
+ * The second slice of docs/ideas/improve-pi-system-prompt.md: the reply shape, not
5
+ * the task discipline. Three concerns, in order — lead with the action, write in
6
+ * Simplified Technical English, restate the context the reader cannot remember.
7
+ *
8
+ * The prose lives in output-style.md so one file feeds both harnesses: this
9
+ * extension appends it to the PI system prompt, and the Claude installer wraps it
10
+ * in a managed CLAUDE.md section. It is read once at load, so the appended text is
11
+ * stable across turns and keeps the prompt-cache prefix intact. The marker doubles
12
+ * as an idempotency guard: a chained prompt that already carries the block is
13
+ * returned unchanged.
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+
18
+ export const OUTPUT_STYLE_MARKER = "## Output style (agent-cortex)";
19
+
20
+ export const OUTPUT_STYLE = readFileSync(new URL("./output-style.md", import.meta.url), "utf-8");
21
+
22
+ /**
23
+ * Append the output-style block to a system prompt.
24
+ *
25
+ * The base prompt is preserved verbatim (minus trailing whitespace) so callers can
26
+ * chain this after other `before_agent_start` handlers. Idempotent: a prompt that
27
+ * already contains the marker is returned unchanged.
28
+ */
29
+ export function appendOutputStyle(systemPrompt: string): string {
30
+ if (systemPrompt.includes(OUTPUT_STYLE_MARKER)) return systemPrompt;
31
+ return `${systemPrompt.trimEnd()}\n\n${OUTPUT_STYLE}`;
32
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@agent-cortex/output-style",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test --import tsx/esm output-style.test.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "devDependencies": {
11
+ "@earendil-works/pi-coding-agent": "^0.78.1",
12
+ "@types/node": "^22.0.0",
13
+ "tsx": "^4.0.0",
14
+ "typescript": "^5.0.0"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "^0.78.1"
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["*.ts"]
12
+ }
@@ -111,8 +111,8 @@ fire-and-forget orchestration:
111
111
  - **`read_agent`** — fallback: read one specific agent's output once you know it has
112
112
  finished (or query its `STILL RUNNING` state directly).
113
113
 
114
- These mirror Copilot CLI's `task`/`read_agent` model; `wait_for_agents` adds the
115
- blocking wait that Copilot's poll loop does manually. Abort (Ctrl+C) propagates: a
114
+ These mirror the `task`/`read_agent` model the skills are authored against;
115
+ `wait_for_agents` adds the blocking wait that a `read_agent` poll loop does manually. Abort (Ctrl+C) propagates: a
116
116
  pending `wait_for_agents` returns immediately with whatever is known so far.
117
117
 
118
118
  ## Output Display
@@ -155,8 +155,7 @@ function isRenderedAgentsTree(agentsDir: string): boolean {
155
155
  * renders the flat pi agents into `<package>/agents`.
156
156
  *
157
157
  * A source checkout contributes nothing: its `agents/` tree is the composable
158
- * source (composed at runtime by the agent-modes extension) alongside flat
159
- * renders for other harnesses — Copilot's, whose tool names pi does not have.
158
+ * source, composed at runtime by the agent-modes extension.
160
159
  */
161
160
  export function resolveUserAgentDirs(packageRoot: string = PACKAGE_ROOT): string[] {
162
161
  const dirs: string[] = [];
package/lib/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- const SUPPORTED_HARNESSES = ["copilot", "claude", "pi"];
1
+ const SUPPORTED_HARNESSES = ["claude", "pi"];
2
2
 
3
3
  // Options accepted by `install <harness>`. `--output`/`--dry-run` are shared by
4
4
  // the claude and pi installers; `--plugin-root` is pi-specific. Unknown or
@@ -87,11 +87,6 @@ Commands:
87
87
  registration cannot run (missing CLI,
88
88
  pre-v2 build); default warns and prints
89
89
  the manual registration commands
90
- Options (copilot):
91
- --output <dir> Write the flat *.agent.md files to <dir>
92
- (default: <package root>/agents — the dir
93
- plugin.json "agents" scans)
94
- --dry-run Print what would be generated without writing
95
90
  Options (pi):
96
91
  (no --output) Materialise the pi package into
97
92
  ~/.agent-cortex/pi: package.json,
@@ -135,7 +130,10 @@ Commands:
135
130
 
136
131
  export function validateHarness(harness) {
137
132
  if (harness === null) {
138
- return { ok: false, error: "Missing harness argument. Supported harnesses: copilot, claude, pi" };
133
+ return {
134
+ ok: false,
135
+ error: `Missing harness argument. Supported harnesses: ${SUPPORTED_HARNESSES.join(", ")}`,
136
+ };
139
137
  }
140
138
  if (!SUPPORTED_HARNESSES.includes(harness)) {
141
139
  return { ok: false, error: `Unknown harness "${harness}". Supported harnesses: ${SUPPORTED_HARNESSES.join(", ")}` };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "1.43.0",
3
+ "version": "2.1.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -35,16 +35,15 @@
35
35
  },
36
36
  "scripts": {
37
37
  "build": "echo 'Build complete — no compilation needed for PI package'",
38
- "lint": "pnpm typecheck && pnpm check:sync",
38
+ "lint": "pnpm typecheck && pnpm check:sync && pnpm check:skills",
39
39
  "check:sync": "node scripts/check-no-sync.mjs",
40
+ "check:skills": "node scripts/check-skill-frontmatter.mjs",
40
41
  "test": "node --test test/*.test.mjs && pnpm -r test",
41
42
  "typecheck": "pnpm -r typecheck",
42
- "build:copilot": "node scripts/build-copilot-agents.mjs",
43
43
  "changeset": "changeset",
44
- "version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:copilot",
44
+ "version-packages": "changeset version",
45
45
  "publish-package": "TARBALL=$(pnpm pack | tail -n 1) && npm publish \"$TARBALL\" --provenance --access public",
46
46
  "publish-noop": "echo 'No publish: the merged PR carries the no-publish label.'",
47
- "release": "changeset publish",
48
- "postinstall": "bash scripts/install-instructions.sh"
47
+ "release": "changeset publish"
49
48
  }
50
49
  }
@@ -0,0 +1,21 @@
1
+ // Repo convention: every SKILL.md frontmatter must parse and declare a name and
2
+ // description, because PI silently drops a skill whose frontmatter throws. Runs
3
+ // via `pnpm lint` (CI) and locally. Scans the repository root, so both skills/
4
+ // and repo-local .agents/skills/ are covered.
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { checkSkills } from "./lib/skill-frontmatter.mjs";
8
+
9
+ const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..");
10
+
11
+ const { checked, failures } = await checkSkills({ root: ROOT });
12
+
13
+ if (failures.length > 0) {
14
+ console.error("✗ Invalid SKILL.md frontmatter — PI would silently drop these skills:");
15
+ for (const { file, errors } of failures) {
16
+ for (const error of errors) console.error(` ${file}: ${error}`);
17
+ }
18
+ process.exit(1);
19
+ }
20
+
21
+ console.log(`✓ SKILL.md frontmatter valid (${checked} files)`);
@@ -1,8 +1,8 @@
1
1
  // Shared composer: reads a composable agent directory (agents/<name>/) and emits
2
- // the harness-specific agent content defined by agents/README.md. The Copilot
3
- // generator (scripts/build-copilot-agents.mjs) and the Claude install-time generator
4
- // (bin/installers/claude.mjs) compose from here, so the composable dirs are
5
- // the single source of truth and the flat/generated files stay in lockstep.
2
+ // the harness-specific agent content defined by agents/README.md. The Claude and pi
3
+ // install-time generators (bin/installers/claude.mjs, bin/installers/pi.mjs) compose
4
+ // from here, so the composable dirs are the single source of truth and the generated
5
+ // files stay in lockstep.
6
6
  //
7
7
  // Composition per the agents/README.md contract:
8
8
  // 1. {{SECTION:name}} is replaced with <dir>/<harness>/<name>.md content.
@@ -15,15 +15,15 @@
15
15
  // silently dropped section.
16
16
  //
17
17
  // Options (the pi installer passes `dropNullTools`, `pluginRoot` and
18
- // `resolveRelativePaths`; the copilot generator and claude installer use the strict defaults):
18
+ // `resolveRelativePaths`; the claude installer uses the strict defaults):
19
19
  // dropNullTools replace null-mapped tool tokens with an empty string and
20
20
  // warn instead of throwing (pi's prose rule per the
21
21
  // token-map contract)
22
22
  // pluginRoot override the plugin_root used for resolved paths
23
23
  // (installers compute it from the actual install layout)
24
24
  // resolveRelativePaths non-keyed {{PATH:...}} args resolve to pluginRoot/arg
25
- // instead of staying verbatim (install time; the build
26
- // scripts leave them bare for Copilot CLI resolution)
25
+ // instead of staying verbatim (install time; without it
26
+ // they stay bare for runtime resolution)
27
27
  // warn callback for drop/omit warnings (default: no-op; the
28
28
  // pi installer collects them into its result)
29
29
  //
@@ -61,7 +61,7 @@ function noop() {}
61
61
  * the harness's section files before calling this.
62
62
  *
63
63
  * @param {string} content Source text containing {{TOOL:...}}/{{PATH:...}} tokens
64
- * @param {string} harness Harness id: "copilot" | "claude" | "pi"
64
+ * @param {string} harness Harness id: "claude" | "pi"
65
65
  * @param {object} tokenMap Parsed token-map.json (from loadTokenMap)
66
66
  * @param {object} options { dropNullTools, pluginRoot, resolveRelativePaths, warn, context }
67
67
  * @returns {string}
@@ -99,7 +99,7 @@ export function substituteTokens(content, harness, tokenMap, options = {}) {
99
99
  *
100
100
  * @param {string} root Package root (contains agents/ and token-map.json)
101
101
  * @param {string} name Agent directory name (e.g. "ralph", "plan")
102
- * @param {string} harness Harness id: "copilot" | "claude" | "pi"
102
+ * @param {string} harness Harness id: "claude" | "pi"
103
103
  * @returns {{ name: string, description: string, tools: string[], model?: string, argumentHint?: string, body: string }}
104
104
  */
105
105
  export async function composeAgent(root, name, harness, options = {}) {
@@ -218,7 +218,7 @@ function resolvePath(arg, harness, tokenMap, opts = {}) {
218
218
  *
219
219
  * @param {string[]} tools Canonical tool names from a harness frontmatter.json
220
220
  * @param {object} tokenMap Parsed token-map.json (from loadTokenMap)
221
- * @param {string} harness Harness id: "copilot" | "claude" | "pi"
221
+ * @param {string} harness Harness id: "claude" | "pi"
222
222
  * @param {object} options { warn }
223
223
  * @returns {string[]}
224
224
  */
@@ -0,0 +1,135 @@
1
+ // SKILL.md frontmatter validation, shared by `pnpm lint` (via
2
+ // scripts/check-skill-frontmatter.mjs) and its tests.
3
+ //
4
+ // PI reads a skill's frontmatter with `parse` from the `yaml` package and
5
+ // silently drops the skill when that parse throws — only a warning diagnostic
6
+ // is emitted. A skill whose frontmatter is broken therefore disappears from the
7
+ // prompt with no visible failure. This module makes that failure loud: every
8
+ // SKILL.md must parse, declare a non-empty `name` and `description`, and keep
9
+ // the description within PI's 1024-character limit.
10
+ //
11
+ // Frontmatter extraction mirrors PI's parseFrontmatter (dist/utils/frontmatter.js), so a
12
+ // SKILL.md that PI drops — frontmatter that will not parse, or no description — fails here
13
+ // too. The check is stricter than PI in two places PI only warns about: `name` must be a
14
+ // non-empty frontmatter field (PI falls back to the directory name), and a description over
15
+ // 1024 characters is an error.
16
+ //
17
+ // Known gaps, each verified against PI's loader and left unhandled on purpose:
18
+ // - A symlinked SKILL.md is skipped: `entry.isFile()` is false for a symlink, while PI
19
+ // stats through the link and loads it. No symlinked SKILL.md exists in this repo.
20
+ // - Duplicate `name` values across skill roots are not detected: validation is per-file
21
+ // with no name index, while PI drops the loser of a name collision.
22
+ import { readdir, readFile } from "node:fs/promises";
23
+ import { join, relative } from "node:path";
24
+ import { parse } from "yaml";
25
+
26
+ /** PI's maximum description length — a longer description is rejected. */
27
+ export const MAX_DESCRIPTION_LENGTH = 1024;
28
+
29
+ const SKILL_FILE = "SKILL.md";
30
+ const SKIPPED_DIRS = new Set([".git", "node_modules"]);
31
+
32
+ /**
33
+ * Return the YAML text between the leading `---` delimiters, or null when the
34
+ * document has no frontmatter block. Mirrors PI's extraction step, including
35
+ * newline normalisation and BOM stripping.
36
+ */
37
+ function extractYaml(content) {
38
+ const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
39
+ if (!normalized.startsWith("---")) return null;
40
+
41
+ const endIndex = normalized.indexOf("\n---", 3);
42
+ if (endIndex === -1) return null;
43
+
44
+ return normalized.slice(4, endIndex);
45
+ }
46
+
47
+ /** True when `value` is a string with at least one non-whitespace character. */
48
+ function isNonEmptyString(value) {
49
+ return typeof value === "string" && value.trim() !== "";
50
+ }
51
+
52
+ /**
53
+ * Validate one SKILL.md document.
54
+ *
55
+ * @param {string} content - Raw SKILL.md contents.
56
+ * @returns {string[]} Human-readable problems; empty when the frontmatter is valid.
57
+ */
58
+ export function validateSkillFrontmatter(content) {
59
+ const yamlText = extractYaml(content);
60
+ if (yamlText === null) {
61
+ return ["frontmatter block is missing (expected '---' delimiters)"];
62
+ }
63
+
64
+ let frontmatter;
65
+ try {
66
+ frontmatter = parse(yamlText);
67
+ } catch (error) {
68
+ const message = error instanceof Error ? error.message.split("\n")[0] : "unknown parse error";
69
+ return [`frontmatter is not valid YAML: ${message}`];
70
+ }
71
+
72
+ if (frontmatter === null || typeof frontmatter !== "object" || Array.isArray(frontmatter)) {
73
+ return ["frontmatter is not a YAML mapping of name/description fields"];
74
+ }
75
+
76
+ const errors = [];
77
+ if (!isNonEmptyString(frontmatter.name)) {
78
+ errors.push("frontmatter is missing a non-empty name");
79
+ }
80
+ if (!isNonEmptyString(frontmatter.description)) {
81
+ errors.push("frontmatter is missing a non-empty description");
82
+ } else if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) {
83
+ errors.push(
84
+ `description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${frontmatter.description.length})`,
85
+ );
86
+ }
87
+
88
+ return errors;
89
+ }
90
+
91
+ /** Recursively collect every SKILL.md below `dir`, skipping vendored trees. */
92
+ async function findSkillFiles(dir) {
93
+ const found = [];
94
+ let entries;
95
+ try {
96
+ entries = await readdir(dir, { withFileTypes: true });
97
+ } catch {
98
+ return found;
99
+ }
100
+
101
+ for (const entry of entries) {
102
+ const full = join(dir, entry.name);
103
+ if (entry.isDirectory()) {
104
+ if (SKIPPED_DIRS.has(entry.name)) continue;
105
+ found.push(...(await findSkillFiles(full)));
106
+ } else if (entry.isFile() && entry.name === SKILL_FILE) {
107
+ found.push(full);
108
+ }
109
+ }
110
+
111
+ return found;
112
+ }
113
+
114
+ /**
115
+ * Validate every SKILL.md under `root`.
116
+ *
117
+ * @param {{ root: string }} options
118
+ * @returns {Promise<{ checked: number, failures: Array<{ file: string, errors: string[] }> }>}
119
+ * `checked` counts the SKILL.md files examined; `failures` holds one entry per
120
+ * invalid file, with `file` relative to `root`.
121
+ */
122
+ export async function checkSkills({ root }) {
123
+ const files = await findSkillFiles(root);
124
+ const failures = [];
125
+
126
+ for (const file of files) {
127
+ const content = await readFile(file, "utf-8");
128
+ const errors = validateSkillFrontmatter(content);
129
+ if (errors.length > 0) {
130
+ failures.push({ file: relative(root, file), errors });
131
+ }
132
+ }
133
+
134
+ return { checked: files.length, failures };
135
+ }