@kolisachint/hoocode-agent 0.5.30 → 0.5.31
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 +103 -0
- package/dist/core/agent-selection-eval.d.ts +91 -0
- package/dist/core/agent-selection-eval.d.ts.map +1 -0
- package/dist/core/agent-selection-eval.js +186 -0
- package/dist/core/agent-selection-eval.js.map +1 -0
- package/dist/core/builtin-skills.d.ts +61 -0
- package/dist/core/builtin-skills.d.ts.map +1 -0
- package/dist/core/builtin-skills.js +106 -0
- package/dist/core/builtin-skills.js.map +1 -0
- package/dist/core/extensions/plugins/default-marketplace/.agents-plugin/marketplace.json +5 -1
- package/dist/core/extensions/plugins/trigger-judge.d.ts +42 -0
- package/dist/core/extensions/plugins/trigger-judge.d.ts.map +1 -0
- package/dist/core/extensions/plugins/trigger-judge.js +121 -0
- package/dist/core/extensions/plugins/trigger-judge.js.map +1 -0
- package/dist/core/external-tools.d.ts +67 -0
- package/dist/core/external-tools.d.ts.map +1 -0
- package/dist/core/external-tools.js +120 -0
- package/dist/core/external-tools.js.map +1 -0
- package/dist/core/mode-prompts.d.ts +15 -3
- package/dist/core/mode-prompts.d.ts.map +1 -1
- package/dist/core/mode-prompts.js +17 -29
- package/dist/core/mode-prompts.js.map +1 -1
- package/dist/core/tools/propose-plugin.d.ts.map +1 -1
- package/dist/core/tools/propose-plugin.js +7 -7
- package/dist/core/tools/propose-plugin.js.map +1 -1
- package/dist/core/tools/subagent.d.ts +7 -1
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +12 -24
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/extensions/core/modes.d.ts.map +1 -1
- package/dist/extensions/core/modes.js +21 -23
- package/dist/extensions/core/modes.js.map +1 -1
- package/dist/extensions/core/scaffold.d.ts.map +1 -1
- package/dist/extensions/core/scaffold.js +28 -24
- package/dist/extensions/core/scaffold.js.map +1 -1
- package/dist/init-templates.generated.d.ts +3 -0
- package/dist/init-templates.generated.d.ts.map +1 -1
- package/dist/init-templates.generated.js +12 -0
- package/dist/init-templates.generated.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +14 -1
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +7 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +152 -11
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +5 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +30 -0
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +35 -0
- package/dist/utils/tools-manager.js.map +1 -1
- package/docs/modes.md +4 -0
- package/docs/plugins.md +10 -0
- package/docs/settings.md +42 -0
- package/docs/skills.md +30 -0
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +5 -4
- package/templates/prompts/grill-bridge.md +1 -0
- package/templates/prompts/grill-me.md +7 -0
- package/templates/prompts/grill-plan.md +9 -0
- package/templates/prompts/task-background-agents.md +2 -0
- package/templates/prompts/task-background-none.md +1 -0
- package/templates/prompts/task-main.md +19 -0
- package/templates/skills/plugin-authoring/SKILL.md +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,108 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.31] - 2026-08-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `/settings` has an **External tools** category. hoocode ships five optional
|
|
8
|
+
Rust binaries — `rg`, `fd`, `embsearch`, `webtools`, `voicetools` — that
|
|
9
|
+
expand what it can do, and nothing in the product ever said so. hoocode works
|
|
10
|
+
without every one of them, which is exactly why they stayed invisible: search
|
|
11
|
+
silently got slower, semantic ranking silently never happened, and web and
|
|
12
|
+
voice were features nobody knew were there. Each row shows live status
|
|
13
|
+
(installed / found on `PATH` / env override / not installed), and opens a
|
|
14
|
+
detail view naming what it enables, what hoocode does instead when it is
|
|
15
|
+
missing, where the release comes from, and the env vars that steer it.
|
|
16
|
+
|
|
17
|
+
- hoocode can ship skills of its own. It read them from `~/.agents/skills`,
|
|
18
|
+
`.hoocode/skills`, `.claude/skills` and installed packages — every source
|
|
19
|
+
except itself — which is why it shipped three subagents and zero skills while
|
|
20
|
+
telling users skills are the extension unit. Built-ins are catalogued in
|
|
21
|
+
`core/builtin-skills.ts` and load at lowest precedence, so a skill of the same
|
|
22
|
+
name from anywhere else wins and the collision is reported.
|
|
23
|
+
|
|
24
|
+
A skill costs its description on every turn, so each built-in can be gated on
|
|
25
|
+
the feature it serves rather than on everyone's token budget.
|
|
26
|
+
|
|
27
|
+
- The first one: `plugin-authoring`, the craft half of `ProposePlugin`/
|
|
28
|
+
`UpdatePlugin` — when a capability is worth extracting, naming and describing
|
|
29
|
+
it so it triggers again, portability rules, and the hook trap where a changed
|
|
30
|
+
command adds a second hook instead of replacing one. Gated on
|
|
31
|
+
`enablePluginTools`, which is off by default, so a default session pays
|
|
32
|
+
nothing for it.
|
|
33
|
+
|
|
34
|
+
Built-ins are materialized to a content-addressed cache under
|
|
35
|
+
`~/.hoocode/cache/builtin-skills/`. A skill is loaded by reading its file, so
|
|
36
|
+
its location has to be a real path, and the compiled standalone binary has no
|
|
37
|
+
install directory to read from; materializing the same embedded copy
|
|
38
|
+
everywhere keeps the skill set identical across npm, pnpm, source and the
|
|
39
|
+
binary. If the cache cannot be written the built-ins are absent and everything
|
|
40
|
+
else runs normally. `--no-skills` and `--light` suppress them.
|
|
41
|
+
|
|
42
|
+
- An agent-selection eval: `bun run agent-eval` scores the built-in agent roster
|
|
43
|
+
against a gold set of real tasks, reporting how often each agent is chosen
|
|
44
|
+
when it should be, how often the parent correctly keeps work inline, and a
|
|
45
|
+
confusion matrix naming which agent loses to which. It reuses the plugin G4
|
|
46
|
+
trigger harness rather than adding a second one, and describes each agent with
|
|
47
|
+
the summarized text `<available_agents>` actually emits.
|
|
48
|
+
|
|
49
|
+
This exists to settle whether `plan` and `explore` are two agents or one:
|
|
50
|
+
they ship with the same tools, the same isolation and the same background
|
|
51
|
+
flag, and `complexity` on the Task tool already expresses the only other
|
|
52
|
+
difference. That was being argued from intuition; it is now measurable.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- The G4 plugin trigger gate never had a judge. `trigger-eval.ts` takes its
|
|
57
|
+
model call as a parameter so scoring stays testable, and nothing in the tree
|
|
58
|
+
ever passed one — so every G4 run has reported `not-run` since it was written.
|
|
59
|
+
`createLlmTriggerJudge` is that judge, shared with the agent-selection eval.
|
|
60
|
+
|
|
61
|
+
- `/new-skill`, `/new-agent` and `/new-command` scaffolded different content
|
|
62
|
+
depending on whether `--platform` was set. Each command has two write paths —
|
|
63
|
+
the per-vendor emitters and the plain `.hoocode/` writer — and each carried
|
|
64
|
+
its own copy of the body, which had drifted in both directions: the
|
|
65
|
+
`.hoocode/` command body documented the `${@:N}` / `${@:N:L}` slice
|
|
66
|
+
placeholders that the platform path silently omitted, and the `.hoocode/`
|
|
67
|
+
agent body identified the agent as running inside hoocode where the platform
|
|
68
|
+
one did not. Both paths read one definition now, and the richer text won in
|
|
69
|
+
each case.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- The four built-in mode prompts have one home. `templates/modes/<mode>/system.md`
|
|
74
|
+
is now the only copy: `core/mode-prompts.ts` re-exports the embedded copy as
|
|
75
|
+
`DEFAULT_MODE_PROMPTS` instead of carrying a second, hand-written set. The two
|
|
76
|
+
had already drifted — `/init` scaffolds the template text into a project, so a
|
|
77
|
+
user who ran it and a user who did not were getting differently worded mode
|
|
78
|
+
rules from the same version.
|
|
79
|
+
|
|
80
|
+
- The `/grill` phases moved out of TypeScript into `templates/prompts/*.md`.
|
|
81
|
+
They are prose the runtime injects verbatim — no interpolation, no branching —
|
|
82
|
+
so they are edited as prose now. The message `/grill` produces is unchanged.
|
|
83
|
+
|
|
84
|
+
- The Task delegation appendix moved to `templates/prompts/task-main.md` and its
|
|
85
|
+
two `task-background-*` variants. At ~600 tok/turn it is the largest block of
|
|
86
|
+
always-on text hoocode emits once the Task tool is on, and it had exactly one
|
|
87
|
+
interpolation slot in otherwise static prose, so a string constant bought
|
|
88
|
+
nothing. The rendered prompt is byte-identical on both the with- and
|
|
89
|
+
without-background-agents branches.
|
|
90
|
+
|
|
91
|
+
- `ProposePlugin` and `UpdatePlugin` shed ~55% of their always-on prompt
|
|
92
|
+
guidance (~626 to ~301 tok/turn when the plugin system is enabled). What was
|
|
93
|
+
removed was how-to-author-well guidance, which is not a tool contract and now
|
|
94
|
+
lives in the `plugin-authoring` skill; what stayed is the trigger, the
|
|
95
|
+
transparency rule for autonomous authoring, the hook trap, and the two hard
|
|
96
|
+
prohibitions. Several removed lines also restated the tools' own descriptions
|
|
97
|
+
or each other.
|
|
98
|
+
|
|
99
|
+
- Settings rows that are inert without one of those binaries now say so. The
|
|
100
|
+
`web` and `semantic search` tool-group switches, the web tools timeout and the
|
|
101
|
+
voice silence window carry a `needs <binary>` marker and explain the fallback
|
|
102
|
+
and whether hoocode will fetch the binary. The rows stay settable — the
|
|
103
|
+
setting is what makes hoocode fetch the binary in the first place, and hiding
|
|
104
|
+
a row when its dependency is missing would recreate the same silence.
|
|
105
|
+
|
|
3
106
|
## [0.5.30] - 2026-08-23
|
|
4
107
|
|
|
5
108
|
### Added
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does the agent roster steer a dispatch to the right agent?
|
|
3
|
+
*
|
|
4
|
+
* The `Task` tool asks the model to pick a `subagent_type` from
|
|
5
|
+
* `<available_agents>`, and that block contains nothing but each agent's
|
|
6
|
+
* summarized description. So agent selection is the same question G4 asks about
|
|
7
|
+
* skills — "given these descriptions and this situation, which one fires?" —
|
|
8
|
+
* and it reuses the same harness rather than growing a second one.
|
|
9
|
+
*
|
|
10
|
+
* What this exists to decide: `plan` and `explore` ship with the same tools, the
|
|
11
|
+
* same isolation and the same `background` flag, differing only in model tier
|
|
12
|
+
* and output contract — and `complexity` on the Task tool already expresses the
|
|
13
|
+
* tier. Whether they are two agents or one is a question about whether the model
|
|
14
|
+
* can actually tell them apart from their descriptions, which is measurable and
|
|
15
|
+
* was previously being argued from intuition.
|
|
16
|
+
*
|
|
17
|
+
* The candidates use `summarizeAgentDescription`, not the raw frontmatter: the
|
|
18
|
+
* summary is what the system prompt actually emits, and evaluating the full
|
|
19
|
+
* description would score text the model never sees.
|
|
20
|
+
*/
|
|
21
|
+
import { type TriggerCandidate, type TriggerCase, type TriggerEvalOutcome, type TriggerJudge } from "./extensions/plugins/trigger-eval.js";
|
|
22
|
+
/** How often one agent was chosen where another was expected. */
|
|
23
|
+
export interface ConfusionEntry {
|
|
24
|
+
expected: string;
|
|
25
|
+
actual: string;
|
|
26
|
+
count: number;
|
|
27
|
+
}
|
|
28
|
+
export interface AgentSelectionReport {
|
|
29
|
+
corpusHash: string;
|
|
30
|
+
agents: string[];
|
|
31
|
+
caseCount: number;
|
|
32
|
+
/** Cases where the expected agent was chosen, over cases expecting any agent. */
|
|
33
|
+
accuracy?: number;
|
|
34
|
+
/** Cases correctly left to the parent, over cases expecting no delegation. */
|
|
35
|
+
inlineAccuracy?: number;
|
|
36
|
+
/** Every wrong pick, most frequent first. The pairs here are the finding. */
|
|
37
|
+
confusion: ConfusionEntry[];
|
|
38
|
+
/** Per-agent recall: chosen / expected. An agent nobody picks is dead weight. */
|
|
39
|
+
perAgent: Array<{
|
|
40
|
+
agent: string;
|
|
41
|
+
expected: number;
|
|
42
|
+
chosen: number;
|
|
43
|
+
recall: number;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The built-in agents as judge candidates, described exactly as the system
|
|
48
|
+
* prompt describes them.
|
|
49
|
+
*
|
|
50
|
+
* `own: true` for all of them: unlike a plugin eval there is no foreign roster
|
|
51
|
+
* to discriminate against, so every case is scored against the same closed set.
|
|
52
|
+
* The `expect: null` cases carry the discriminative half instead — they ask
|
|
53
|
+
* whether the model declines to delegate work it should keep.
|
|
54
|
+
*/
|
|
55
|
+
export declare function agentCandidates(cwd?: string): TriggerCandidate[];
|
|
56
|
+
/** Load a gold set: `{ "cases": [{ "prompt": "...", "expect": "explore" | null }] }`. */
|
|
57
|
+
export declare function loadAgentCases(file: string): TriggerCase[] | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Every expected agent in the gold set must exist in the roster.
|
|
60
|
+
*
|
|
61
|
+
* A typo'd or removed agent name would otherwise score as a permanent miss and
|
|
62
|
+
* read as a description problem, which is the most expensive way to be wrong
|
|
63
|
+
* about an eval.
|
|
64
|
+
*/
|
|
65
|
+
export declare function validateAgentCases(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[]): string[];
|
|
66
|
+
/**
|
|
67
|
+
* Turn a scored run into the report that answers the design question.
|
|
68
|
+
*
|
|
69
|
+
* `runTriggerEval`'s recall/specificity are the right numbers for a plugin
|
|
70
|
+
* defending itself against a foreign roster. Here the roster is closed, so the
|
|
71
|
+
* useful shape is a confusion matrix: which agent loses to which, and how often.
|
|
72
|
+
* "explore and plan are interchangeable" is a claim about one cell.
|
|
73
|
+
*/
|
|
74
|
+
export declare function summarizeAgentSelection(outcome: Extract<TriggerEvalOutcome, {
|
|
75
|
+
status: "ran";
|
|
76
|
+
}>, candidates: readonly TriggerCandidate[]): AgentSelectionReport;
|
|
77
|
+
export type AgentSelectionOutcome = {
|
|
78
|
+
status: "not-run";
|
|
79
|
+
reason: string;
|
|
80
|
+
} | {
|
|
81
|
+
status: "ran";
|
|
82
|
+
report: AgentSelectionReport;
|
|
83
|
+
outcome: Extract<TriggerEvalOutcome, {
|
|
84
|
+
status: "ran";
|
|
85
|
+
}>;
|
|
86
|
+
};
|
|
87
|
+
/** Score the roster against a gold set. Never throws; a missing model is `not-run`. */
|
|
88
|
+
export declare function runAgentSelectionEval(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[] | undefined, judge: TriggerJudge | undefined): Promise<AgentSelectionOutcome>;
|
|
89
|
+
/** Human-readable report for the CLI. */
|
|
90
|
+
export declare function formatAgentSelectionReport(report: AgentSelectionReport): string;
|
|
91
|
+
//# sourceMappingURL=agent-selection-eval.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-selection-eval.d.ts","sourceRoot":"","sources":["../../src/core/agent-selection-eval.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH,OAAO,EAEN,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,MAAM,sCAAsC,CAAC;AAE9C,iEAAiE;AACjE,MAAM,WAAW,cAAc;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,oBAAoB;IACpC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,iFAAiF;IACjF,QAAQ,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrF;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAsB,GAAG,gBAAgB,EAAE,CAU/E;AAED,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,GAAG,SAAS,CAiBtE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,SAAS,gBAAgB,EAAE,EAAE,KAAK,EAAE,SAAS,WAAW,EAAE,GAAG,MAAM,EAAE,CASnH;AASD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACtC,OAAO,EAAE,OAAO,CAAC,kBAAkB,EAAE;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,CAAC,EACvD,UAAU,EAAE,SAAS,gBAAgB,EAAE,GACrC,oBAAoB,CAiDtB;AAED,MAAM,MAAM,qBAAqB,GAC9B;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,oBAAoB,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC,kBAAkB,EAAE;QAAE,MAAM,EAAE,KAAK,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC;AAE5G,uFAAuF;AACvF,wBAAsB,qBAAqB,CAC1C,UAAU,EAAE,SAAS,gBAAgB,EAAE,EACvC,KAAK,EAAE,SAAS,WAAW,EAAE,GAAG,SAAS,EACzC,KAAK,EAAE,YAAY,GAAG,SAAS,GAC7B,OAAO,CAAC,qBAAqB,CAAC,CAmBhC;AAED,yCAAyC;AACzC,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAkB/E","sourcesContent":["/**\n * Does the agent roster steer a dispatch to the right agent?\n *\n * The `Task` tool asks the model to pick a `subagent_type` from\n * `<available_agents>`, and that block contains nothing but each agent's\n * summarized description. So agent selection is the same question G4 asks about\n * skills — \"given these descriptions and this situation, which one fires?\" —\n * and it reuses the same harness rather than growing a second one.\n *\n * What this exists to decide: `plan` and `explore` ship with the same tools, the\n * same isolation and the same `background` flag, differing only in model tier\n * and output contract — and `complexity` on the Task tool already expresses the\n * tier. Whether they are two agents or one is a question about whether the model\n * can actually tell them apart from their descriptions, which is measurable and\n * was previously being argued from intuition.\n *\n * The candidates use `summarizeAgentDescription`, not the raw frontmatter: the\n * summary is what the system prompt actually emits, and evaluating the full\n * description would score text the model never sees.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { loadAgentRegistry, summarizeAgentDescription } from \"./agent-registry.js\";\nimport {\n\trunTriggerEval,\n\ttype TriggerCandidate,\n\ttype TriggerCase,\n\ttype TriggerEvalOutcome,\n\ttype TriggerJudge,\n} from \"./extensions/plugins/trigger-eval.js\";\n\n/** How often one agent was chosen where another was expected. */\nexport interface ConfusionEntry {\n\texpected: string;\n\tactual: string;\n\tcount: number;\n}\n\nexport interface AgentSelectionReport {\n\tcorpusHash: string;\n\tagents: string[];\n\tcaseCount: number;\n\t/** Cases where the expected agent was chosen, over cases expecting any agent. */\n\taccuracy?: number;\n\t/** Cases correctly left to the parent, over cases expecting no delegation. */\n\tinlineAccuracy?: number;\n\t/** Every wrong pick, most frequent first. The pairs here are the finding. */\n\tconfusion: ConfusionEntry[];\n\t/** Per-agent recall: chosen / expected. An agent nobody picks is dead weight. */\n\tperAgent: Array<{ agent: string; expected: number; chosen: number; recall: number }>;\n}\n\n/**\n * The built-in agents as judge candidates, described exactly as the system\n * prompt describes them.\n *\n * `own: true` for all of them: unlike a plugin eval there is no foreign roster\n * to discriminate against, so every case is scored against the same closed set.\n * The `expect: null` cases carry the discriminative half instead — they ask\n * whether the model declines to delegate work it should keep.\n */\nexport function agentCandidates(cwd: string = process.cwd()): TriggerCandidate[] {\n\tconst registry = loadAgentRegistry({ cwd, includeBuiltins: true, includeClaude: false });\n\treturn registry\n\t\t.list()\n\t\t.map((agent) => ({\n\t\t\tname: agent.name,\n\t\t\tdescription: summarizeAgentDescription(agent.description ?? \"\"),\n\t\t\town: true,\n\t\t}))\n\t\t.sort((a, b) => a.name.localeCompare(b.name));\n}\n\n/** Load a gold set: `{ \"cases\": [{ \"prompt\": \"...\", \"expect\": \"explore\" | null }] }`. */\nexport function loadAgentCases(file: string): TriggerCase[] | undefined {\n\tif (!existsSync(file)) return undefined;\n\ttry {\n\t\tconst raw = JSON.parse(readFileSync(file, \"utf-8\")) as { cases?: unknown };\n\t\tif (!Array.isArray(raw.cases)) return undefined;\n\t\tconst cases = raw.cases.filter(\n\t\t\t(c): c is TriggerCase =>\n\t\t\t\t!!c &&\n\t\t\t\ttypeof c === \"object\" &&\n\t\t\t\ttypeof (c as TriggerCase).prompt === \"string\" &&\n\t\t\t\t(c as TriggerCase).prompt.trim().length > 0 &&\n\t\t\t\t((c as TriggerCase).expect === null || typeof (c as TriggerCase).expect === \"string\"),\n\t\t);\n\t\treturn cases.length > 0 ? cases : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Every expected agent in the gold set must exist in the roster.\n *\n * A typo'd or removed agent name would otherwise score as a permanent miss and\n * read as a description problem, which is the most expensive way to be wrong\n * about an eval.\n */\nexport function validateAgentCases(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[]): string[] {\n\tconst known = new Set(candidates.map((c) => c.name));\n\tconst problems: string[] = [];\n\tfor (const [i, testCase] of cases.entries()) {\n\t\tif (testCase.expect !== null && !known.has(testCase.expect)) {\n\t\t\tproblems.push(`case ${i} expects \"${testCase.expect}\", which is not in the roster`);\n\t\t}\n\t}\n\treturn problems;\n}\n\nfunction hashCorpus(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[]): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const c of candidates) h.update(`${c.name} ${c.description} `);\n\tfor (const c of cases) h.update(`${c.prompt} ${c.expect ?? \"\"} `);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n\n/**\n * Turn a scored run into the report that answers the design question.\n *\n * `runTriggerEval`'s recall/specificity are the right numbers for a plugin\n * defending itself against a foreign roster. Here the roster is closed, so the\n * useful shape is a confusion matrix: which agent loses to which, and how often.\n * \"explore and plan are interchangeable\" is a claim about one cell.\n */\nexport function summarizeAgentSelection(\n\toutcome: Extract<TriggerEvalOutcome, { status: \"ran\" }>,\n\tcandidates: readonly TriggerCandidate[],\n): AgentSelectionReport {\n\tconst confusion = new Map<string, ConfusionEntry>();\n\tconst expectedCounts = new Map<string, number>();\n\tconst chosenCounts = new Map<string, number>();\n\tlet delegated = 0;\n\tlet delegatedCorrect = 0;\n\tlet inline = 0;\n\tlet inlineCorrect = 0;\n\n\tfor (const result of outcome.record.results) {\n\t\tif (result.expected === null) {\n\t\t\tinline++;\n\t\t\tif (result.actual === null) inlineCorrect++;\n\t\t\tcontinue;\n\t\t}\n\t\tdelegated++;\n\t\texpectedCounts.set(result.expected, (expectedCounts.get(result.expected) ?? 0) + 1);\n\t\tif (result.correct) {\n\t\t\tdelegatedCorrect++;\n\t\t\tchosenCounts.set(result.expected, (chosenCounts.get(result.expected) ?? 0) + 1);\n\t\t\tcontinue;\n\t\t}\n\t\t// \"(none)\" is a real outcome, not a missing value: declining to delegate\n\t\t// work that should have been delegated is a different failure from picking\n\t\t// the wrong agent, and collapsing them would hide which one is happening.\n\t\tconst actual = result.actual ?? \"(none)\";\n\t\tconst key = `${result.expected} ${actual}`;\n\t\tconst entry = confusion.get(key) ?? { expected: result.expected, actual, count: 0 };\n\t\tentry.count++;\n\t\tconfusion.set(key, entry);\n\t}\n\n\tconst perAgent = candidates\n\t\t.map((candidate) => {\n\t\t\tconst expected = expectedCounts.get(candidate.name) ?? 0;\n\t\t\tconst chosen = chosenCounts.get(candidate.name) ?? 0;\n\t\t\treturn { agent: candidate.name, expected, chosen, recall: expected > 0 ? chosen / expected : 0 };\n\t\t})\n\t\t.sort((a, b) => a.recall - b.recall);\n\n\treturn {\n\t\tcorpusHash: outcome.record.corpusHash,\n\t\tagents: candidates.map((c) => c.name),\n\t\tcaseCount: outcome.record.caseCount,\n\t\taccuracy: delegated > 0 ? delegatedCorrect / delegated : undefined,\n\t\tinlineAccuracy: inline > 0 ? inlineCorrect / inline : undefined,\n\t\tconfusion: [...confusion.values()].sort((a, b) => b.count - a.count),\n\t\tperAgent,\n\t};\n}\n\nexport type AgentSelectionOutcome =\n\t| { status: \"not-run\"; reason: string }\n\t| { status: \"ran\"; report: AgentSelectionReport; outcome: Extract<TriggerEvalOutcome, { status: \"ran\" }> };\n\n/** Score the roster against a gold set. Never throws; a missing model is `not-run`. */\nexport async function runAgentSelectionEval(\n\tcandidates: readonly TriggerCandidate[],\n\tcases: readonly TriggerCase[] | undefined,\n\tjudge: TriggerJudge | undefined,\n): Promise<AgentSelectionOutcome> {\n\tif (cases && cases.length > 0) {\n\t\tconst problems = validateAgentCases(candidates, cases);\n\t\tif (problems.length > 0) return { status: \"not-run\", reason: `invalid gold set: ${problems.join(\"; \")}` };\n\t}\n\n\tconst outcome = await runTriggerEval(\"agent-selection\", candidates, cases, judge);\n\tif (outcome.status === \"not-run\") return outcome;\n\n\treturn {\n\t\tstatus: \"ran\",\n\t\treport: {\n\t\t\t...summarizeAgentSelection(outcome, candidates),\n\t\t\t// The shared harness hashes its own way; restate it over exactly what\n\t\t\t// this eval judged so two reports are comparable on their own terms.\n\t\t\tcorpusHash: hashCorpus(candidates, cases ?? []),\n\t\t},\n\t\toutcome,\n\t};\n}\n\n/** Human-readable report for the CLI. */\nexport function formatAgentSelectionReport(report: AgentSelectionReport): string {\n\tconst pct = (v: number | undefined) => (v === undefined ? \"n/a\" : `${Math.round(v * 100)}%`);\n\tconst lines = [\n\t\t`corpus ${report.corpusHash} - ${report.caseCount} case(s) over ${report.agents.length} agent(s): ${report.agents.join(\", \")}`,\n\t\t`delegation accuracy ${pct(report.accuracy)} inline accuracy ${pct(report.inlineAccuracy)}`,\n\t\t\"\",\n\t\t\"per agent (lowest recall first):\",\n\t];\n\tfor (const row of report.perAgent) {\n\t\tlines.push(` ${row.agent.padEnd(18)} ${row.chosen}/${row.expected} chosen (${pct(row.recall)})`);\n\t}\n\tif (report.confusion.length > 0) {\n\t\tlines.push(\"\", \"confusions (expected then actual):\");\n\t\tfor (const entry of report.confusion) {\n\t\t\tlines.push(` ${entry.expected} => ${entry.actual} x${entry.count}`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does the agent roster steer a dispatch to the right agent?
|
|
3
|
+
*
|
|
4
|
+
* The `Task` tool asks the model to pick a `subagent_type` from
|
|
5
|
+
* `<available_agents>`, and that block contains nothing but each agent's
|
|
6
|
+
* summarized description. So agent selection is the same question G4 asks about
|
|
7
|
+
* skills — "given these descriptions and this situation, which one fires?" —
|
|
8
|
+
* and it reuses the same harness rather than growing a second one.
|
|
9
|
+
*
|
|
10
|
+
* What this exists to decide: `plan` and `explore` ship with the same tools, the
|
|
11
|
+
* same isolation and the same `background` flag, differing only in model tier
|
|
12
|
+
* and output contract — and `complexity` on the Task tool already expresses the
|
|
13
|
+
* tier. Whether they are two agents or one is a question about whether the model
|
|
14
|
+
* can actually tell them apart from their descriptions, which is measurable and
|
|
15
|
+
* was previously being argued from intuition.
|
|
16
|
+
*
|
|
17
|
+
* The candidates use `summarizeAgentDescription`, not the raw frontmatter: the
|
|
18
|
+
* summary is what the system prompt actually emits, and evaluating the full
|
|
19
|
+
* description would score text the model never sees.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { loadAgentRegistry, summarizeAgentDescription } from "./agent-registry.js";
|
|
24
|
+
import { runTriggerEval, } from "./extensions/plugins/trigger-eval.js";
|
|
25
|
+
/**
|
|
26
|
+
* The built-in agents as judge candidates, described exactly as the system
|
|
27
|
+
* prompt describes them.
|
|
28
|
+
*
|
|
29
|
+
* `own: true` for all of them: unlike a plugin eval there is no foreign roster
|
|
30
|
+
* to discriminate against, so every case is scored against the same closed set.
|
|
31
|
+
* The `expect: null` cases carry the discriminative half instead — they ask
|
|
32
|
+
* whether the model declines to delegate work it should keep.
|
|
33
|
+
*/
|
|
34
|
+
export function agentCandidates(cwd = process.cwd()) {
|
|
35
|
+
const registry = loadAgentRegistry({ cwd, includeBuiltins: true, includeClaude: false });
|
|
36
|
+
return registry
|
|
37
|
+
.list()
|
|
38
|
+
.map((agent) => ({
|
|
39
|
+
name: agent.name,
|
|
40
|
+
description: summarizeAgentDescription(agent.description ?? ""),
|
|
41
|
+
own: true,
|
|
42
|
+
}))
|
|
43
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
}
|
|
45
|
+
/** Load a gold set: `{ "cases": [{ "prompt": "...", "expect": "explore" | null }] }`. */
|
|
46
|
+
export function loadAgentCases(file) {
|
|
47
|
+
if (!existsSync(file))
|
|
48
|
+
return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(readFileSync(file, "utf-8"));
|
|
51
|
+
if (!Array.isArray(raw.cases))
|
|
52
|
+
return undefined;
|
|
53
|
+
const cases = raw.cases.filter((c) => !!c &&
|
|
54
|
+
typeof c === "object" &&
|
|
55
|
+
typeof c.prompt === "string" &&
|
|
56
|
+
c.prompt.trim().length > 0 &&
|
|
57
|
+
(c.expect === null || typeof c.expect === "string"));
|
|
58
|
+
return cases.length > 0 ? cases : undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Every expected agent in the gold set must exist in the roster.
|
|
66
|
+
*
|
|
67
|
+
* A typo'd or removed agent name would otherwise score as a permanent miss and
|
|
68
|
+
* read as a description problem, which is the most expensive way to be wrong
|
|
69
|
+
* about an eval.
|
|
70
|
+
*/
|
|
71
|
+
export function validateAgentCases(candidates, cases) {
|
|
72
|
+
const known = new Set(candidates.map((c) => c.name));
|
|
73
|
+
const problems = [];
|
|
74
|
+
for (const [i, testCase] of cases.entries()) {
|
|
75
|
+
if (testCase.expect !== null && !known.has(testCase.expect)) {
|
|
76
|
+
problems.push(`case ${i} expects "${testCase.expect}", which is not in the roster`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
|
81
|
+
function hashCorpus(candidates, cases) {
|
|
82
|
+
const h = createHash("sha256");
|
|
83
|
+
for (const c of candidates)
|
|
84
|
+
h.update(`${c.name} ${c.description} `);
|
|
85
|
+
for (const c of cases)
|
|
86
|
+
h.update(`${c.prompt} ${c.expect ?? ""} `);
|
|
87
|
+
return h.digest("hex").slice(0, 16);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Turn a scored run into the report that answers the design question.
|
|
91
|
+
*
|
|
92
|
+
* `runTriggerEval`'s recall/specificity are the right numbers for a plugin
|
|
93
|
+
* defending itself against a foreign roster. Here the roster is closed, so the
|
|
94
|
+
* useful shape is a confusion matrix: which agent loses to which, and how often.
|
|
95
|
+
* "explore and plan are interchangeable" is a claim about one cell.
|
|
96
|
+
*/
|
|
97
|
+
export function summarizeAgentSelection(outcome, candidates) {
|
|
98
|
+
const confusion = new Map();
|
|
99
|
+
const expectedCounts = new Map();
|
|
100
|
+
const chosenCounts = new Map();
|
|
101
|
+
let delegated = 0;
|
|
102
|
+
let delegatedCorrect = 0;
|
|
103
|
+
let inline = 0;
|
|
104
|
+
let inlineCorrect = 0;
|
|
105
|
+
for (const result of outcome.record.results) {
|
|
106
|
+
if (result.expected === null) {
|
|
107
|
+
inline++;
|
|
108
|
+
if (result.actual === null)
|
|
109
|
+
inlineCorrect++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
delegated++;
|
|
113
|
+
expectedCounts.set(result.expected, (expectedCounts.get(result.expected) ?? 0) + 1);
|
|
114
|
+
if (result.correct) {
|
|
115
|
+
delegatedCorrect++;
|
|
116
|
+
chosenCounts.set(result.expected, (chosenCounts.get(result.expected) ?? 0) + 1);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// "(none)" is a real outcome, not a missing value: declining to delegate
|
|
120
|
+
// work that should have been delegated is a different failure from picking
|
|
121
|
+
// the wrong agent, and collapsing them would hide which one is happening.
|
|
122
|
+
const actual = result.actual ?? "(none)";
|
|
123
|
+
const key = `${result.expected} ${actual}`;
|
|
124
|
+
const entry = confusion.get(key) ?? { expected: result.expected, actual, count: 0 };
|
|
125
|
+
entry.count++;
|
|
126
|
+
confusion.set(key, entry);
|
|
127
|
+
}
|
|
128
|
+
const perAgent = candidates
|
|
129
|
+
.map((candidate) => {
|
|
130
|
+
const expected = expectedCounts.get(candidate.name) ?? 0;
|
|
131
|
+
const chosen = chosenCounts.get(candidate.name) ?? 0;
|
|
132
|
+
return { agent: candidate.name, expected, chosen, recall: expected > 0 ? chosen / expected : 0 };
|
|
133
|
+
})
|
|
134
|
+
.sort((a, b) => a.recall - b.recall);
|
|
135
|
+
return {
|
|
136
|
+
corpusHash: outcome.record.corpusHash,
|
|
137
|
+
agents: candidates.map((c) => c.name),
|
|
138
|
+
caseCount: outcome.record.caseCount,
|
|
139
|
+
accuracy: delegated > 0 ? delegatedCorrect / delegated : undefined,
|
|
140
|
+
inlineAccuracy: inline > 0 ? inlineCorrect / inline : undefined,
|
|
141
|
+
confusion: [...confusion.values()].sort((a, b) => b.count - a.count),
|
|
142
|
+
perAgent,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Score the roster against a gold set. Never throws; a missing model is `not-run`. */
|
|
146
|
+
export async function runAgentSelectionEval(candidates, cases, judge) {
|
|
147
|
+
if (cases && cases.length > 0) {
|
|
148
|
+
const problems = validateAgentCases(candidates, cases);
|
|
149
|
+
if (problems.length > 0)
|
|
150
|
+
return { status: "not-run", reason: `invalid gold set: ${problems.join("; ")}` };
|
|
151
|
+
}
|
|
152
|
+
const outcome = await runTriggerEval("agent-selection", candidates, cases, judge);
|
|
153
|
+
if (outcome.status === "not-run")
|
|
154
|
+
return outcome;
|
|
155
|
+
return {
|
|
156
|
+
status: "ran",
|
|
157
|
+
report: {
|
|
158
|
+
...summarizeAgentSelection(outcome, candidates),
|
|
159
|
+
// The shared harness hashes its own way; restate it over exactly what
|
|
160
|
+
// this eval judged so two reports are comparable on their own terms.
|
|
161
|
+
corpusHash: hashCorpus(candidates, cases ?? []),
|
|
162
|
+
},
|
|
163
|
+
outcome,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Human-readable report for the CLI. */
|
|
167
|
+
export function formatAgentSelectionReport(report) {
|
|
168
|
+
const pct = (v) => (v === undefined ? "n/a" : `${Math.round(v * 100)}%`);
|
|
169
|
+
const lines = [
|
|
170
|
+
`corpus ${report.corpusHash} - ${report.caseCount} case(s) over ${report.agents.length} agent(s): ${report.agents.join(", ")}`,
|
|
171
|
+
`delegation accuracy ${pct(report.accuracy)} inline accuracy ${pct(report.inlineAccuracy)}`,
|
|
172
|
+
"",
|
|
173
|
+
"per agent (lowest recall first):",
|
|
174
|
+
];
|
|
175
|
+
for (const row of report.perAgent) {
|
|
176
|
+
lines.push(` ${row.agent.padEnd(18)} ${row.chosen}/${row.expected} chosen (${pct(row.recall)})`);
|
|
177
|
+
}
|
|
178
|
+
if (report.confusion.length > 0) {
|
|
179
|
+
lines.push("", "confusions (expected then actual):");
|
|
180
|
+
for (const entry of report.confusion) {
|
|
181
|
+
lines.push(` ${entry.expected} => ${entry.actual} x${entry.count}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=agent-selection-eval.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-selection-eval.js","sourceRoot":"","sources":["../../src/core/agent-selection-eval.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Does the agent roster steer a dispatch to the right agent?\n *\n * The `Task` tool asks the model to pick a `subagent_type` from\n * `<available_agents>`, and that block contains nothing but each agent's\n * summarized description. So agent selection is the same question G4 asks about\n * skills — \"given these descriptions and this situation, which one fires?\" —\n * and it reuses the same harness rather than growing a second one.\n *\n * What this exists to decide: `plan` and `explore` ship with the same tools, the\n * same isolation and the same `background` flag, differing only in model tier\n * and output contract — and `complexity` on the Task tool already expresses the\n * tier. Whether they are two agents or one is a question about whether the model\n * can actually tell them apart from their descriptions, which is measurable and\n * was previously being argued from intuition.\n *\n * The candidates use `summarizeAgentDescription`, not the raw frontmatter: the\n * summary is what the system prompt actually emits, and evaluating the full\n * description would score text the model never sees.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { loadAgentRegistry, summarizeAgentDescription } from \"./agent-registry.js\";\nimport {\n\trunTriggerEval,\n\ttype TriggerCandidate,\n\ttype TriggerCase,\n\ttype TriggerEvalOutcome,\n\ttype TriggerJudge,\n} from \"./extensions/plugins/trigger-eval.js\";\n\n/** How often one agent was chosen where another was expected. */\nexport interface ConfusionEntry {\n\texpected: string;\n\tactual: string;\n\tcount: number;\n}\n\nexport interface AgentSelectionReport {\n\tcorpusHash: string;\n\tagents: string[];\n\tcaseCount: number;\n\t/** Cases where the expected agent was chosen, over cases expecting any agent. */\n\taccuracy?: number;\n\t/** Cases correctly left to the parent, over cases expecting no delegation. */\n\tinlineAccuracy?: number;\n\t/** Every wrong pick, most frequent first. The pairs here are the finding. */\n\tconfusion: ConfusionEntry[];\n\t/** Per-agent recall: chosen / expected. An agent nobody picks is dead weight. */\n\tperAgent: Array<{ agent: string; expected: number; chosen: number; recall: number }>;\n}\n\n/**\n * The built-in agents as judge candidates, described exactly as the system\n * prompt describes them.\n *\n * `own: true` for all of them: unlike a plugin eval there is no foreign roster\n * to discriminate against, so every case is scored against the same closed set.\n * The `expect: null` cases carry the discriminative half instead — they ask\n * whether the model declines to delegate work it should keep.\n */\nexport function agentCandidates(cwd: string = process.cwd()): TriggerCandidate[] {\n\tconst registry = loadAgentRegistry({ cwd, includeBuiltins: true, includeClaude: false });\n\treturn registry\n\t\t.list()\n\t\t.map((agent) => ({\n\t\t\tname: agent.name,\n\t\t\tdescription: summarizeAgentDescription(agent.description ?? \"\"),\n\t\t\town: true,\n\t\t}))\n\t\t.sort((a, b) => a.name.localeCompare(b.name));\n}\n\n/** Load a gold set: `{ \"cases\": [{ \"prompt\": \"...\", \"expect\": \"explore\" | null }] }`. */\nexport function loadAgentCases(file: string): TriggerCase[] | undefined {\n\tif (!existsSync(file)) return undefined;\n\ttry {\n\t\tconst raw = JSON.parse(readFileSync(file, \"utf-8\")) as { cases?: unknown };\n\t\tif (!Array.isArray(raw.cases)) return undefined;\n\t\tconst cases = raw.cases.filter(\n\t\t\t(c): c is TriggerCase =>\n\t\t\t\t!!c &&\n\t\t\t\ttypeof c === \"object\" &&\n\t\t\t\ttypeof (c as TriggerCase).prompt === \"string\" &&\n\t\t\t\t(c as TriggerCase).prompt.trim().length > 0 &&\n\t\t\t\t((c as TriggerCase).expect === null || typeof (c as TriggerCase).expect === \"string\"),\n\t\t);\n\t\treturn cases.length > 0 ? cases : undefined;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Every expected agent in the gold set must exist in the roster.\n *\n * A typo'd or removed agent name would otherwise score as a permanent miss and\n * read as a description problem, which is the most expensive way to be wrong\n * about an eval.\n */\nexport function validateAgentCases(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[]): string[] {\n\tconst known = new Set(candidates.map((c) => c.name));\n\tconst problems: string[] = [];\n\tfor (const [i, testCase] of cases.entries()) {\n\t\tif (testCase.expect !== null && !known.has(testCase.expect)) {\n\t\t\tproblems.push(`case ${i} expects \"${testCase.expect}\", which is not in the roster`);\n\t\t}\n\t}\n\treturn problems;\n}\n\nfunction hashCorpus(candidates: readonly TriggerCandidate[], cases: readonly TriggerCase[]): string {\n\tconst h = createHash(\"sha256\");\n\tfor (const c of candidates) h.update(`${c.name} ${c.description} `);\n\tfor (const c of cases) h.update(`${c.prompt} ${c.expect ?? \"\"} `);\n\treturn h.digest(\"hex\").slice(0, 16);\n}\n\n/**\n * Turn a scored run into the report that answers the design question.\n *\n * `runTriggerEval`'s recall/specificity are the right numbers for a plugin\n * defending itself against a foreign roster. Here the roster is closed, so the\n * useful shape is a confusion matrix: which agent loses to which, and how often.\n * \"explore and plan are interchangeable\" is a claim about one cell.\n */\nexport function summarizeAgentSelection(\n\toutcome: Extract<TriggerEvalOutcome, { status: \"ran\" }>,\n\tcandidates: readonly TriggerCandidate[],\n): AgentSelectionReport {\n\tconst confusion = new Map<string, ConfusionEntry>();\n\tconst expectedCounts = new Map<string, number>();\n\tconst chosenCounts = new Map<string, number>();\n\tlet delegated = 0;\n\tlet delegatedCorrect = 0;\n\tlet inline = 0;\n\tlet inlineCorrect = 0;\n\n\tfor (const result of outcome.record.results) {\n\t\tif (result.expected === null) {\n\t\t\tinline++;\n\t\t\tif (result.actual === null) inlineCorrect++;\n\t\t\tcontinue;\n\t\t}\n\t\tdelegated++;\n\t\texpectedCounts.set(result.expected, (expectedCounts.get(result.expected) ?? 0) + 1);\n\t\tif (result.correct) {\n\t\t\tdelegatedCorrect++;\n\t\t\tchosenCounts.set(result.expected, (chosenCounts.get(result.expected) ?? 0) + 1);\n\t\t\tcontinue;\n\t\t}\n\t\t// \"(none)\" is a real outcome, not a missing value: declining to delegate\n\t\t// work that should have been delegated is a different failure from picking\n\t\t// the wrong agent, and collapsing them would hide which one is happening.\n\t\tconst actual = result.actual ?? \"(none)\";\n\t\tconst key = `${result.expected} ${actual}`;\n\t\tconst entry = confusion.get(key) ?? { expected: result.expected, actual, count: 0 };\n\t\tentry.count++;\n\t\tconfusion.set(key, entry);\n\t}\n\n\tconst perAgent = candidates\n\t\t.map((candidate) => {\n\t\t\tconst expected = expectedCounts.get(candidate.name) ?? 0;\n\t\t\tconst chosen = chosenCounts.get(candidate.name) ?? 0;\n\t\t\treturn { agent: candidate.name, expected, chosen, recall: expected > 0 ? chosen / expected : 0 };\n\t\t})\n\t\t.sort((a, b) => a.recall - b.recall);\n\n\treturn {\n\t\tcorpusHash: outcome.record.corpusHash,\n\t\tagents: candidates.map((c) => c.name),\n\t\tcaseCount: outcome.record.caseCount,\n\t\taccuracy: delegated > 0 ? delegatedCorrect / delegated : undefined,\n\t\tinlineAccuracy: inline > 0 ? inlineCorrect / inline : undefined,\n\t\tconfusion: [...confusion.values()].sort((a, b) => b.count - a.count),\n\t\tperAgent,\n\t};\n}\n\nexport type AgentSelectionOutcome =\n\t| { status: \"not-run\"; reason: string }\n\t| { status: \"ran\"; report: AgentSelectionReport; outcome: Extract<TriggerEvalOutcome, { status: \"ran\" }> };\n\n/** Score the roster against a gold set. Never throws; a missing model is `not-run`. */\nexport async function runAgentSelectionEval(\n\tcandidates: readonly TriggerCandidate[],\n\tcases: readonly TriggerCase[] | undefined,\n\tjudge: TriggerJudge | undefined,\n): Promise<AgentSelectionOutcome> {\n\tif (cases && cases.length > 0) {\n\t\tconst problems = validateAgentCases(candidates, cases);\n\t\tif (problems.length > 0) return { status: \"not-run\", reason: `invalid gold set: ${problems.join(\"; \")}` };\n\t}\n\n\tconst outcome = await runTriggerEval(\"agent-selection\", candidates, cases, judge);\n\tif (outcome.status === \"not-run\") return outcome;\n\n\treturn {\n\t\tstatus: \"ran\",\n\t\treport: {\n\t\t\t...summarizeAgentSelection(outcome, candidates),\n\t\t\t// The shared harness hashes its own way; restate it over exactly what\n\t\t\t// this eval judged so two reports are comparable on their own terms.\n\t\t\tcorpusHash: hashCorpus(candidates, cases ?? []),\n\t\t},\n\t\toutcome,\n\t};\n}\n\n/** Human-readable report for the CLI. */\nexport function formatAgentSelectionReport(report: AgentSelectionReport): string {\n\tconst pct = (v: number | undefined) => (v === undefined ? \"n/a\" : `${Math.round(v * 100)}%`);\n\tconst lines = [\n\t\t`corpus ${report.corpusHash} - ${report.caseCount} case(s) over ${report.agents.length} agent(s): ${report.agents.join(\", \")}`,\n\t\t`delegation accuracy ${pct(report.accuracy)} inline accuracy ${pct(report.inlineAccuracy)}`,\n\t\t\"\",\n\t\t\"per agent (lowest recall first):\",\n\t];\n\tfor (const row of report.perAgent) {\n\t\tlines.push(` ${row.agent.padEnd(18)} ${row.chosen}/${row.expected} chosen (${pct(row.recall)})`);\n\t}\n\tif (report.confusion.length > 0) {\n\t\tlines.push(\"\", \"confusions (expected then actual):\");\n\t\tfor (const entry of report.confusion) {\n\t\t\tlines.push(` ${entry.expected} => ${entry.actual} x${entry.count}`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills hoocode itself ships.
|
|
3
|
+
*
|
|
4
|
+
* hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`
|
|
5
|
+
* and installed packages — every source except its own. That gap is why it
|
|
6
|
+
* shipped three subagents and zero skills while telling users skills are the
|
|
7
|
+
* extension unit: there was simply nowhere for a first-party skill to live.
|
|
8
|
+
*
|
|
9
|
+
* The obstacle is that a skill's `<location>` has to be a real readable path —
|
|
10
|
+
* the model loads a skill by `read`ing it — and the Bun-compiled binary has no
|
|
11
|
+
* `templates/` beside it. So rather than resolve the package directory (which
|
|
12
|
+
* differs across npm/pnpm/source/binary layouts and would give the compiled
|
|
13
|
+
* binary a silently degraded skill set), every install materializes the same
|
|
14
|
+
* embedded copy into a cache directory. One code path, same behaviour
|
|
15
|
+
* everywhere.
|
|
16
|
+
*
|
|
17
|
+
* The cache is keyed by content hash, so an upgrade writes a new directory and
|
|
18
|
+
* a dev build that changes a skill without changing the version still takes
|
|
19
|
+
* effect. It is a cache, not user-editable state: `~/.agents/skills` is where a
|
|
20
|
+
* user's own skills go, and nothing here ever writes there.
|
|
21
|
+
*/
|
|
22
|
+
/** What decides whether a built-in skill is registered this session. */
|
|
23
|
+
export interface BuiltinSkillGate {
|
|
24
|
+
/** The `enablePluginTools` setting (the plugin system's master switch). */
|
|
25
|
+
enablePluginTools: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface BuiltinSkill {
|
|
28
|
+
/** Directory name under `templates/skills`, and the skill's own name. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Why hoocode ships it. Documentation, and the catalog test reads it. */
|
|
31
|
+
summary: string;
|
|
32
|
+
/**
|
|
33
|
+
* Registered only when this returns true. A skill costs its description on
|
|
34
|
+
* every turn, so one that only makes sense alongside a feature rides that
|
|
35
|
+
* feature's switch rather than the default user's token budget.
|
|
36
|
+
*
|
|
37
|
+
* Omit for a skill that should always be available.
|
|
38
|
+
*/
|
|
39
|
+
gate?: (options: BuiltinSkillGate) => boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare const BUILTIN_SKILLS: readonly BuiltinSkill[];
|
|
42
|
+
/** Root of the materialized copy for the current content. */
|
|
43
|
+
export declare function builtinSkillsCacheDir(agentDir?: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Write the embedded skills to the cache directory if they are not already
|
|
46
|
+
* there, and return its path.
|
|
47
|
+
*
|
|
48
|
+
* Returns null when nothing could be written — a read-only home, a full disk.
|
|
49
|
+
* That is a degraded session, not a broken one: the caller contributes no skill
|
|
50
|
+
* paths and hoocode runs exactly as it did before these existed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function materializeBuiltinSkills(agentDir?: string): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* The skill directories to load this session, after gating.
|
|
55
|
+
*
|
|
56
|
+
* Returns per-skill directories rather than the root so a gated-off skill is
|
|
57
|
+
* genuinely absent rather than loaded and filtered later — the load is what
|
|
58
|
+
* costs the description on every turn.
|
|
59
|
+
*/
|
|
60
|
+
export declare function builtinSkillPaths(gate: BuiltinSkillGate, agentDir?: string): string[];
|
|
61
|
+
//# sourceMappingURL=builtin-skills.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builtin-skills.d.ts","sourceRoot":"","sources":["../../src/core/builtin-skills.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAQH,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAChC,2EAA2E;IAC3E,iBAAiB,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,YAAY;IAC5B,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC;CAC9C;AAED,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EASjD,CAAC;AAcF,6DAA6D;AAC7D,wBAAgB,qBAAqB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAE9E;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,QAAQ,GAAE,MAAsB,GAAG,MAAM,EAAE,CAQpG","sourcesContent":["/**\n * Skills hoocode itself ships.\n *\n * hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`\n * and installed packages — every source except its own. That gap is why it\n * shipped three subagents and zero skills while telling users skills are the\n * extension unit: there was simply nowhere for a first-party skill to live.\n *\n * The obstacle is that a skill's `<location>` has to be a real readable path —\n * the model loads a skill by `read`ing it — and the Bun-compiled binary has no\n * `templates/` beside it. So rather than resolve the package directory (which\n * differs across npm/pnpm/source/binary layouts and would give the compiled\n * binary a silently degraded skill set), every install materializes the same\n * embedded copy into a cache directory. One code path, same behaviour\n * everywhere.\n *\n * The cache is keyed by content hash, so an upgrade writes a new directory and\n * a dev build that changes a skill without changing the version still takes\n * effect. It is a cache, not user-editable state: `~/.agents/skills` is where a\n * user's own skills go, and nothing here ever writes there.\n */\n\nimport { createHash } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { getAgentDir } from \"../config.js\";\nimport { EMBEDDED_SKILLS } from \"../init-templates.generated.js\";\n\n/** What decides whether a built-in skill is registered this session. */\nexport interface BuiltinSkillGate {\n\t/** The `enablePluginTools` setting (the plugin system's master switch). */\n\tenablePluginTools: boolean;\n}\n\nexport interface BuiltinSkill {\n\t/** Directory name under `templates/skills`, and the skill's own name. */\n\tname: string;\n\t/** Why hoocode ships it. Documentation, and the catalog test reads it. */\n\tsummary: string;\n\t/**\n\t * Registered only when this returns true. A skill costs its description on\n\t * every turn, so one that only makes sense alongside a feature rides that\n\t * feature's switch rather than the default user's token budget.\n\t *\n\t * Omit for a skill that should always be available.\n\t */\n\tgate?: (options: BuiltinSkillGate) => boolean;\n}\n\nexport const BUILTIN_SKILLS: readonly BuiltinSkill[] = [\n\t{\n\t\tname: \"plugin-authoring\",\n\t\tsummary:\n\t\t\t\"The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.\",\n\t\t// Useless without the tools it describes, and those are off by default,\n\t\t// so this costs nothing for a user who never enables the plugin system.\n\t\tgate: (options) => options.enablePluginTools,\n\t},\n];\n\n/** Stable short hash of the embedded skill tree; the cache directory's name. */\nfunction contentHash(): string {\n\tconst hash = createHash(\"sha256\");\n\tfor (const key of Object.keys(EMBEDDED_SKILLS).sort()) {\n\t\thash.update(key);\n\t\thash.update(\"\\0\");\n\t\thash.update(EMBEDDED_SKILLS[key] ?? \"\");\n\t\thash.update(\"\\0\");\n\t}\n\treturn hash.digest(\"hex\").slice(0, 12);\n}\n\n/** Root of the materialized copy for the current content. */\nexport function builtinSkillsCacheDir(agentDir: string = getAgentDir()): string {\n\treturn join(agentDir, \"cache\", \"builtin-skills\", contentHash());\n}\n\n/**\n * Write the embedded skills to the cache directory if they are not already\n * there, and return its path.\n *\n * Returns null when nothing could be written — a read-only home, a full disk.\n * That is a degraded session, not a broken one: the caller contributes no skill\n * paths and hoocode runs exactly as it did before these existed.\n */\nexport function materializeBuiltinSkills(agentDir: string = getAgentDir()): string | null {\n\tconst root = builtinSkillsCacheDir(agentDir);\n\ttry {\n\t\tfor (const [relativePath, content] of Object.entries(EMBEDDED_SKILLS)) {\n\t\t\tconst target = join(root, relativePath);\n\t\t\t// Content is hash-addressed, so an existing file with the right size is\n\t\t\t// already correct; re-reading beats re-writing on every startup.\n\t\t\tif (existsSync(target) && readFileSync(target, \"utf-8\") === content) continue;\n\t\t\tmkdirSync(dirname(target), { recursive: true });\n\t\t\t// Write-then-rename so a killed process never leaves a half-written\n\t\t\t// SKILL.md that would parse as a malformed skill on the next run.\n\t\t\tconst temp = `${target}.${process.pid}.tmp`;\n\t\t\twriteFileSync(temp, content, \"utf-8\");\n\t\t\trenameSync(temp, target);\n\t\t}\n\t\treturn root;\n\t} catch {\n\t\t// Drop a partial tree so the next run rebuilds it rather than loading a\n\t\t// half-written skill. The cleanup gets its own guard: `force` swallows\n\t\t// ENOENT but not ENOTDIR, and a cleanup that throws would turn the\n\t\t// degraded path back into a crash — which is the failure this whole\n\t\t// branch exists to prevent.\n\t\ttry {\n\t\t\trmSync(root, { recursive: true, force: true });\n\t\t} catch {}\n\t\treturn null;\n\t}\n}\n\n/**\n * The skill directories to load this session, after gating.\n *\n * Returns per-skill directories rather than the root so a gated-off skill is\n * genuinely absent rather than loaded and filtered later — the load is what\n * costs the description on every turn.\n */\nexport function builtinSkillPaths(gate: BuiltinSkillGate, agentDir: string = getAgentDir()): string[] {\n\tconst enabled = BUILTIN_SKILLS.filter((skill) => !skill.gate || skill.gate(gate));\n\tif (enabled.length === 0) return [];\n\n\tconst root = materializeBuiltinSkills(agentDir);\n\tif (!root) return [];\n\n\treturn enabled.map((skill) => join(root, skill.name)).filter((dir) => existsSync(dir));\n}\n"]}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills hoocode itself ships.
|
|
3
|
+
*
|
|
4
|
+
* hoocode reads skills from `~/.agents/skills`, `.hoocode/skills`, `.claude/skills`
|
|
5
|
+
* and installed packages — every source except its own. That gap is why it
|
|
6
|
+
* shipped three subagents and zero skills while telling users skills are the
|
|
7
|
+
* extension unit: there was simply nowhere for a first-party skill to live.
|
|
8
|
+
*
|
|
9
|
+
* The obstacle is that a skill's `<location>` has to be a real readable path —
|
|
10
|
+
* the model loads a skill by `read`ing it — and the Bun-compiled binary has no
|
|
11
|
+
* `templates/` beside it. So rather than resolve the package directory (which
|
|
12
|
+
* differs across npm/pnpm/source/binary layouts and would give the compiled
|
|
13
|
+
* binary a silently degraded skill set), every install materializes the same
|
|
14
|
+
* embedded copy into a cache directory. One code path, same behaviour
|
|
15
|
+
* everywhere.
|
|
16
|
+
*
|
|
17
|
+
* The cache is keyed by content hash, so an upgrade writes a new directory and
|
|
18
|
+
* a dev build that changes a skill without changing the version still takes
|
|
19
|
+
* effect. It is a cache, not user-editable state: `~/.agents/skills` is where a
|
|
20
|
+
* user's own skills go, and nothing here ever writes there.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { getAgentDir } from "../config.js";
|
|
26
|
+
import { EMBEDDED_SKILLS } from "../init-templates.generated.js";
|
|
27
|
+
export const BUILTIN_SKILLS = [
|
|
28
|
+
{
|
|
29
|
+
name: "plugin-authoring",
|
|
30
|
+
summary: "The craft half of ProposePlugin/UpdatePlugin: when a capability is worth extracting, naming it so it triggers again, portability, and the hook trap.",
|
|
31
|
+
// Useless without the tools it describes, and those are off by default,
|
|
32
|
+
// so this costs nothing for a user who never enables the plugin system.
|
|
33
|
+
gate: (options) => options.enablePluginTools,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
/** Stable short hash of the embedded skill tree; the cache directory's name. */
|
|
37
|
+
function contentHash() {
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
for (const key of Object.keys(EMBEDDED_SKILLS).sort()) {
|
|
40
|
+
hash.update(key);
|
|
41
|
+
hash.update("\0");
|
|
42
|
+
hash.update(EMBEDDED_SKILLS[key] ?? "");
|
|
43
|
+
hash.update("\0");
|
|
44
|
+
}
|
|
45
|
+
return hash.digest("hex").slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
/** Root of the materialized copy for the current content. */
|
|
48
|
+
export function builtinSkillsCacheDir(agentDir = getAgentDir()) {
|
|
49
|
+
return join(agentDir, "cache", "builtin-skills", contentHash());
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Write the embedded skills to the cache directory if they are not already
|
|
53
|
+
* there, and return its path.
|
|
54
|
+
*
|
|
55
|
+
* Returns null when nothing could be written — a read-only home, a full disk.
|
|
56
|
+
* That is a degraded session, not a broken one: the caller contributes no skill
|
|
57
|
+
* paths and hoocode runs exactly as it did before these existed.
|
|
58
|
+
*/
|
|
59
|
+
export function materializeBuiltinSkills(agentDir = getAgentDir()) {
|
|
60
|
+
const root = builtinSkillsCacheDir(agentDir);
|
|
61
|
+
try {
|
|
62
|
+
for (const [relativePath, content] of Object.entries(EMBEDDED_SKILLS)) {
|
|
63
|
+
const target = join(root, relativePath);
|
|
64
|
+
// Content is hash-addressed, so an existing file with the right size is
|
|
65
|
+
// already correct; re-reading beats re-writing on every startup.
|
|
66
|
+
if (existsSync(target) && readFileSync(target, "utf-8") === content)
|
|
67
|
+
continue;
|
|
68
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
69
|
+
// Write-then-rename so a killed process never leaves a half-written
|
|
70
|
+
// SKILL.md that would parse as a malformed skill on the next run.
|
|
71
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
72
|
+
writeFileSync(temp, content, "utf-8");
|
|
73
|
+
renameSync(temp, target);
|
|
74
|
+
}
|
|
75
|
+
return root;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Drop a partial tree so the next run rebuilds it rather than loading a
|
|
79
|
+
// half-written skill. The cleanup gets its own guard: `force` swallows
|
|
80
|
+
// ENOENT but not ENOTDIR, and a cleanup that throws would turn the
|
|
81
|
+
// degraded path back into a crash — which is the failure this whole
|
|
82
|
+
// branch exists to prevent.
|
|
83
|
+
try {
|
|
84
|
+
rmSync(root, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The skill directories to load this session, after gating.
|
|
92
|
+
*
|
|
93
|
+
* Returns per-skill directories rather than the root so a gated-off skill is
|
|
94
|
+
* genuinely absent rather than loaded and filtered later — the load is what
|
|
95
|
+
* costs the description on every turn.
|
|
96
|
+
*/
|
|
97
|
+
export function builtinSkillPaths(gate, agentDir = getAgentDir()) {
|
|
98
|
+
const enabled = BUILTIN_SKILLS.filter((skill) => !skill.gate || skill.gate(gate));
|
|
99
|
+
if (enabled.length === 0)
|
|
100
|
+
return [];
|
|
101
|
+
const root = materializeBuiltinSkills(agentDir);
|
|
102
|
+
if (!root)
|
|
103
|
+
return [];
|
|
104
|
+
return enabled.map((skill) => join(root, skill.name)).filter((dir) => existsSync(dir));
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=builtin-skills.js.map
|