@kolisachint/hoocode-agent 0.5.29 → 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/core/agent-selection-eval.d.ts +91 -0
  3. package/dist/core/agent-selection-eval.d.ts.map +1 -0
  4. package/dist/core/agent-selection-eval.js +186 -0
  5. package/dist/core/agent-selection-eval.js.map +1 -0
  6. package/dist/core/builtin-skills.d.ts +61 -0
  7. package/dist/core/builtin-skills.d.ts.map +1 -0
  8. package/dist/core/builtin-skills.js +106 -0
  9. package/dist/core/builtin-skills.js.map +1 -0
  10. package/dist/core/extensions/plugins/default-marketplace/.agents-plugin/marketplace.json +5 -1
  11. package/dist/core/extensions/plugins/trigger-judge.d.ts +42 -0
  12. package/dist/core/extensions/plugins/trigger-judge.d.ts.map +1 -0
  13. package/dist/core/extensions/plugins/trigger-judge.js +121 -0
  14. package/dist/core/extensions/plugins/trigger-judge.js.map +1 -0
  15. package/dist/core/external-tools.d.ts +67 -0
  16. package/dist/core/external-tools.d.ts.map +1 -0
  17. package/dist/core/external-tools.js +120 -0
  18. package/dist/core/external-tools.js.map +1 -0
  19. package/dist/core/light.d.ts +9 -0
  20. package/dist/core/light.d.ts.map +1 -1
  21. package/dist/core/light.js +12 -4
  22. package/dist/core/light.js.map +1 -1
  23. package/dist/core/mode-prompts.d.ts +15 -3
  24. package/dist/core/mode-prompts.d.ts.map +1 -1
  25. package/dist/core/mode-prompts.js +17 -29
  26. package/dist/core/mode-prompts.js.map +1 -1
  27. package/dist/core/tools/propose-plugin.d.ts.map +1 -1
  28. package/dist/core/tools/propose-plugin.js +7 -7
  29. package/dist/core/tools/propose-plugin.js.map +1 -1
  30. package/dist/core/tools/subagent.d.ts +7 -1
  31. package/dist/core/tools/subagent.d.ts.map +1 -1
  32. package/dist/core/tools/subagent.js +12 -24
  33. package/dist/core/tools/subagent.js.map +1 -1
  34. package/dist/extensions/core/modes.d.ts.map +1 -1
  35. package/dist/extensions/core/modes.js +21 -23
  36. package/dist/extensions/core/modes.js.map +1 -1
  37. package/dist/extensions/core/scaffold.d.ts.map +1 -1
  38. package/dist/extensions/core/scaffold.js +28 -24
  39. package/dist/extensions/core/scaffold.js.map +1 -1
  40. package/dist/init-templates.generated.d.ts +3 -0
  41. package/dist/init-templates.generated.d.ts.map +1 -1
  42. package/dist/init-templates.generated.js +12 -0
  43. package/dist/init-templates.generated.js.map +1 -1
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +14 -1
  46. package/dist/main.js.map +1 -1
  47. package/dist/modes/interactive/components/settings-selector.d.ts +27 -0
  48. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  49. package/dist/modes/interactive/components/settings-selector.js +256 -45
  50. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  52. package/dist/modes/interactive/interactive-mode.js +26 -1
  53. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  54. package/dist/utils/tools-manager.d.ts +30 -0
  55. package/dist/utils/tools-manager.d.ts.map +1 -1
  56. package/dist/utils/tools-manager.js +35 -0
  57. package/dist/utils/tools-manager.js.map +1 -1
  58. package/docs/modes.md +4 -0
  59. package/docs/plugins.md +10 -0
  60. package/docs/settings.md +63 -0
  61. package/docs/skills.md +30 -0
  62. package/docs/usage.md +3 -3
  63. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  64. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  65. package/examples/extensions/sandbox/package.json +1 -1
  66. package/examples/extensions/with-deps/package.json +1 -1
  67. package/package.json +5 -4
  68. package/templates/prompts/grill-bridge.md +1 -0
  69. package/templates/prompts/grill-me.md +7 -0
  70. package/templates/prompts/grill-plan.md +9 -0
  71. package/templates/prompts/task-background-agents.md +2 -0
  72. package/templates/prompts/task-background-none.md +1 -0
  73. package/templates/prompts/task-main.md +19 -0
  74. package/templates/skills/plugin-authoring/SKILL.md +81 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,132 @@
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
+
106
+ ## [0.5.30] - 2026-08-23
107
+
108
+ ### Added
109
+
110
+ - `/settings` prices what it is about to cost you. A line under the pane reports
111
+ the fixed per-turn surface — system prompt plus active tool schemas, the text
112
+ re-sent on every request — and each tool carries its own schema price beside
113
+ its switch, including the tools that are off, where the price is what turning
114
+ one back on will cost. The numbers are the same measurement
115
+ `--print-token-surface` prints, re-measured after every change, so a toggle
116
+ that moves the surface shows what it moved.
117
+
118
+ - The `light` preset is editable from `/settings`. It was the one low-token
119
+ control with no home in the pane, which is backwards for the setting whose
120
+ entire purpose is the token budget. It reads at startup, so it applies on the
121
+ next session.
122
+
123
+ - `/settings` grouping: a **Context** category now holds auto-compact, context
124
+ GC and the light preset. Those three decide the token budget and were in three
125
+ different places — auto-compact alone at the top level, context GC filed under
126
+ tool settings, light nowhere. Tool output display joins the truncation caps it
127
+ belongs with under one **Tool output** row, and every top-level row now opens
128
+ something rather than cycling in place.
129
+
3
130
  ## [0.5.29] - 2026-08-23
4
131
 
5
132
  ### 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"]}