@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.
- package/.agents/skills/style-versioning/SKILL.md +5 -6
- package/.github/workflows/ci.yml +4 -11
- package/AGENTS.md +13 -17
- package/CHANGELOG.md +66 -0
- package/README.md +57 -57
- package/agents/README.md +9 -23
- package/agents/ralph/agent.md +13 -17
- package/agents/ralph/claude/polling.md +1 -1
- package/agents-native/ralph.md +15 -5
- package/bin/agent-cortex.mjs +0 -16
- package/bin/installers/claude.mjs +82 -11
- package/docs/claude-hooks.md +16 -0
- package/docs/ideas/improve-pi-system-prompt.md +9 -3
- package/docs/research/disable-model-invocation.md +5 -26
- package/docs/skills/skill-anatomy.md +5 -3
- package/docs/technical-direction/harness-config.md +6 -14
- package/docs/user-preferences.md +2 -2
- package/extensions/agent-modes/README.md +3 -3
- package/extensions/agent-modes/discover.ts +6 -6
- package/extensions/output-style/README.md +58 -0
- package/extensions/output-style/index.ts +23 -0
- package/extensions/output-style/output-style.md +43 -0
- package/extensions/output-style/output-style.test.ts +50 -0
- package/extensions/output-style/output-style.ts +32 -0
- package/extensions/output-style/package.json +19 -0
- package/extensions/output-style/tsconfig.json +12 -0
- package/extensions/subagent/README.md +2 -2
- package/extensions/subagent/agents.ts +1 -2
- package/lib/cli.mjs +5 -7
- package/package.json +5 -6
- package/scripts/check-skill-frontmatter.mjs +21 -0
- package/scripts/lib/compose-agent.mjs +10 -10
- package/scripts/lib/skill-frontmatter.mjs +135 -0
- package/skills/workflow/ralph/REFERENCE.md +34 -11
- package/skills/workflow/ralph/SKILL.md +10 -10
- package/skills/workflow/ralph/scripts/pending-gates.mjs +134 -0
- package/test/cli.test.mjs +11 -10
- package/test/installer.test.mjs +143 -1
- package/test/pending-gates.test.mjs +147 -0
- package/test/skill-frontmatter.test.mjs +137 -0
- package/token-map.README.md +9 -12
- package/token-map.json +26 -31
- package/agents/plan/copilot/frontmatter.json +0 -6
- package/agents/plan.agent.md +0 -59
- package/agents/ralph/copilot/frontmatter.json +0 -6
- package/agents/ralph/copilot/polling.md +0 -62
- package/agents/ralph-plan/copilot/frontmatter.json +0 -6
- package/agents/ralph-plan.agent.md +0 -126
- package/agents/ralph.agent.md +0 -331
- package/agents/strategy/copilot/frontmatter.json +0 -6
- package/agents/strategy.agent.md +0 -112
- package/bin/installers/copilot.mjs +0 -124
- package/hooks.json +0 -10
- package/plugin.json +0 -35
- package/scripts/build-copilot-agents.mjs +0 -30
- package/scripts/install-instructions.sh +0 -82
- package/scripts/sync-plugin-version.sh +0 -17
- 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 (
|
|
22
|
-
*
|
|
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 = "
|
|
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 (
|
|
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
|
+
}
|
|
@@ -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
|
|
115
|
-
blocking wait that
|
|
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
|
|
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 = ["
|
|
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 {
|
|
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.
|
|
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
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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;
|
|
26
|
-
//
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
+
}
|