@dsalazardev/ai-hub-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { ENGINE_COUNTS, type EngineManifest } from '../engine/engine.js';
2
2
  import { type ResolvedPreset } from '../presets/resolve.js';
3
3
  import type { PresetDangerPattern } from '../presets/schema.js';
4
+ import type { SelectionMode } from '../catalog.js';
4
5
  export declare const MANIFEST_FILE = "manifest.yaml";
5
6
  export declare const INVENTORY_RELATIVE = "core/manifest.json";
6
7
  export { ENGINE_COUNTS };
@@ -38,14 +39,21 @@ export interface ProjectManifest {
38
39
  skills: {
39
40
  source: string;
40
41
  publish: string[];
42
+ selection?: SelectionMode;
41
43
  };
42
44
  agents: {
43
45
  source: string;
44
46
  publish: string[];
47
+ selection?: SelectionMode;
45
48
  };
46
49
  rules: {
47
50
  source: string;
48
51
  publish: string[];
52
+ selection?: SelectionMode;
53
+ };
54
+ catalog?: {
55
+ version: string;
56
+ hash: string;
49
57
  };
50
58
  mcp?: {
51
59
  source: string;
@@ -90,6 +98,15 @@ export interface ManifestInventory {
90
98
  }>;
91
99
  labels: string[];
92
100
  targets: string[];
101
+ selection?: {
102
+ skills: SelectionMode;
103
+ agents: SelectionMode;
104
+ rules: SelectionMode;
105
+ };
106
+ catalog?: {
107
+ version: string;
108
+ hash: string;
109
+ } | null;
93
110
  agents: string[];
94
111
  skills: string[];
95
112
  rules: string[];
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
2
3
  import { PACKAGE_NAME } from '../package-info.js';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -148,7 +149,12 @@ export async function validateManifestData(data, root = process.cwd()) {
148
149
  return { manifest, engine, preset };
149
150
  }
150
151
  export function inventoryPath(root) {
151
- return path.join(root, INVENTORY_RELATIVE);
152
+ // En el checkout del motor el inventario vive en core/ porque ese es el paquete que se publica.
153
+ // En un proyecto consumidor pertenece a .ai-hub/, junto al arnes instalado.
154
+ const engineMarker = path.join(root, 'core', 'src', 'paths.ts');
155
+ return existsSync(engineMarker)
156
+ ? path.join(root, INVENTORY_RELATIVE)
157
+ : path.join(root, '.ai-hub', 'manifest.json');
152
158
  }
153
159
  export function buildInventory(manifest, engine) {
154
160
  const roles = engine.roles.map((role) => role.id).sort();
@@ -169,6 +175,18 @@ export function buildInventory(manifest, engine) {
169
175
  commands,
170
176
  labels,
171
177
  targets: [...manifest.targets].sort(),
178
+ ...(manifest.skills.selection !== undefined ||
179
+ manifest.agents.selection !== undefined ||
180
+ manifest.rules.selection !== undefined
181
+ ? {
182
+ selection: {
183
+ skills: manifest.skills.selection ?? 'preset',
184
+ agents: manifest.agents.selection ?? 'preset',
185
+ rules: manifest.rules.selection ?? 'preset',
186
+ },
187
+ }
188
+ : {}),
189
+ ...(manifest.catalog !== undefined ? { catalog: manifest.catalog } : {}),
172
190
  agents,
173
191
  skills,
174
192
  rules,
@@ -15,4 +15,12 @@ export interface MaterializeResult {
15
15
  collisions: string[];
16
16
  }
17
17
  export declare function readScaffold(root: string): Promise<ScaffoldRecord>;
18
- export declare function materializePreset(root: string, preset: ResolvedPreset): Promise<MaterializeResult>;
18
+ export declare function materializePreset(root: string, preset: ResolvedPreset, selectedRules?: string[]): Promise<MaterializeResult>;
19
+ /** Copia una skill del catálogo o del preset a `.ai-hub/skills/<id>`. */
20
+ export declare function materializeSkill(root: string, id: string, sourceDir?: string, sourceKey?: string): Promise<MaterializeResult>;
21
+ /** Copia un agente del catálogo o del preset a `.ai-hub/agents/<id>.md`. */
22
+ export declare function materializeAgent(root: string, id: string, sourceFile?: string, sourceKey?: string): Promise<MaterializeResult>;
23
+ /** Copia las 4 rúbricas universales desde el paquete a `.ai-hub/rules/skill-quality/`. */
24
+ export declare function materializeUniversalRules(root: string, preset: ResolvedPreset, selectedRules?: string[]): Promise<MaterializeResult>;
25
+ /** Copia skills y agentes seleccionados del catálogo. */
26
+ export declare function materializeCatalog(root: string, skillIds: string[], agentIds: string[]): Promise<MaterializeResult>;
@@ -1,10 +1,11 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { fileURLToPath } from 'node:url';
3
+ import { mapLimit } from '../concurrency.js';
4
+ import { ENGINE_SKILLS_DIR } from '../engine/engine.js';
5
+ import { ENGINE_AGENTS_DIR, ENGINE_RULES_DIR, RUBRIC_RULE_IDS, normalizeRuleId, } from '../catalog.js';
4
6
  import { sha256 } from '../sync/lock.js';
5
7
  import { findPresetAsset } from './resolve.js';
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- export const ENGINE_AGENTS = path.join(__dirname, '..', '..', 'agents');
8
+ export const ENGINE_AGENTS = ENGINE_AGENTS_DIR;
8
9
  export const SCAFFOLD_RELATIVE = '.ai-hub/.scaffold.json';
9
10
  async function exists(file) {
10
11
  return fs.access(file).then(() => true, () => false);
@@ -38,10 +39,11 @@ export async function readScaffold(root) {
38
39
  }
39
40
  async function writeScaffold(root, record) {
40
41
  const file = path.join(root, SCAFFOLD_RELATIVE);
42
+ const files = Object.fromEntries(Object.entries(record.files).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
41
43
  await fs.mkdir(path.dirname(file), { recursive: true });
42
- await fs.writeFile(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
44
+ await fs.writeFile(file, `${JSON.stringify({ version: 1, files }, null, 2)}\n`, 'utf8');
43
45
  }
44
- async function copyOnce(root, source, destination, record, result) {
46
+ async function copyOnce(root, source, destination, record, result, sourceKey = source) {
45
47
  const content = await fs.readFile(source, 'utf8');
46
48
  const hash = sha256(content);
47
49
  const abs = path.join(root, destination);
@@ -49,6 +51,7 @@ async function copyOnce(root, source, destination, record, result) {
49
51
  const previous = record.files[destination];
50
52
  const current = sha256(await fs.readFile(abs, 'utf8').catch(() => ''));
51
53
  if (previous && previous.sha256 === current) {
54
+ previous.source = sourceKey;
52
55
  result.kept.push(destination);
53
56
  return;
54
57
  }
@@ -57,18 +60,22 @@ async function copyOnce(root, source, destination, record, result) {
57
60
  }
58
61
  await fs.mkdir(path.dirname(abs), { recursive: true });
59
62
  await fs.writeFile(abs, content, 'utf8');
60
- record.files[destination] = { sha256: hash, source };
63
+ record.files[destination] = { sha256: hash, source: sourceKey };
61
64
  result.written.push(destination);
62
65
  }
63
- export async function materializePreset(root, preset) {
66
+ export async function materializePreset(root, preset, selectedRules) {
64
67
  const result = { written: [], kept: [], collisions: [] };
65
68
  const record = await readScaffold(root);
69
+ const wantedRules = selectedRules === undefined ? null : new Set(selectedRules);
66
70
  for (const rule of preset.rules) {
71
+ if (wantedRules !== null && !wantedRules.has(normalizeRuleId(rule))) {
72
+ continue;
73
+ }
67
74
  const source = await findPresetAsset(preset.chain, rule);
68
75
  if (source === null) {
69
76
  throw new Error(`el preset "${preset.id}" declara la regla "${rule}" pero no existe`);
70
77
  }
71
- await copyOnce(root, source, `.ai-hub/rules/${path.basename(rule)}`, record, result);
78
+ await copyOnce(root, source, `.ai-hub/rules/${normalizeRuleId(rule)}.md`, record, result, `@preset/${preset.id}/${rule.replace(/\\/g, '/')}`);
72
79
  }
73
80
  for (const agent of preset.agents) {
74
81
  const fromPreset = await findPresetAsset(preset.chain, `agents/${agent}.md`);
@@ -76,13 +83,13 @@ export async function materializePreset(root, preset) {
76
83
  if (!(await exists(source))) {
77
84
  throw new Error(`el agente "${agent}" no existe ni en el preset ni en el motor`);
78
85
  }
79
- await copyOnce(root, source, `.ai-hub/agents/${agent}.md`, record, result);
86
+ await copyOnce(root, source, `.ai-hub/agents/${agent}.md`, record, result, fromPreset ? `@preset/${preset.id}/agents/${agent}.md` : `@engine/agents/${agent}.md`);
80
87
  }
81
88
  const harness = await findPresetAsset(preset.chain, 'harness');
82
89
  if (harness !== null) {
83
90
  for (const source of await walk(harness)) {
84
91
  const relative = path.relative(harness, source).split(path.sep).join('/');
85
- await copyOnce(root, source, `.ai-hub/harness/${relative}`, record, result);
92
+ await copyOnce(root, source, `.ai-hub/harness/${relative}`, record, result, `@preset/${preset.id}/harness/${relative}`);
86
93
  }
87
94
  }
88
95
  for (const skill of preset.skills) {
@@ -93,9 +100,93 @@ export async function materializePreset(root, preset) {
93
100
  const files = await walk(dir);
94
101
  for (const source of files) {
95
102
  const relative = path.relative(dir, source).split(path.sep).join('/');
96
- await copyOnce(root, source, `.ai-hub/skills/${skill}/${relative}`, record, result);
103
+ await copyOnce(root, source, `.ai-hub/skills/${skill}/${relative}`, record, result, `@preset/${preset.id}/skills/${skill}/${relative}`);
104
+ }
105
+ }
106
+ await writeScaffold(root, record);
107
+ return result;
108
+ }
109
+ /** Copia una skill del catálogo o del preset a `.ai-hub/skills/<id>`. */
110
+ export async function materializeSkill(root, id, sourceDir = path.join(ENGINE_SKILLS_DIR, id), sourceKey = `@engine/skills/shared/${id}`) {
111
+ const result = { written: [], kept: [], collisions: [] };
112
+ const record = await readScaffold(root);
113
+ if (!(await exists(sourceDir))) {
114
+ throw new Error(`la skill "${id}" no existe en ${sourceDir}`);
115
+ }
116
+ for (const source of await walk(sourceDir)) {
117
+ const relative = path.relative(sourceDir, source).split(path.sep).join('/');
118
+ await copyOnce(root, source, `.ai-hub/skills/${id}/${relative}`, record, result, `${sourceKey}/${relative}`);
119
+ }
120
+ await writeScaffold(root, record);
121
+ return result;
122
+ }
123
+ /** Copia un agente del catálogo o del preset a `.ai-hub/agents/<id>.md`. */
124
+ export async function materializeAgent(root, id, sourceFile = path.join(ENGINE_AGENTS, `${id}.md`), sourceKey = `@engine/agents/${id}.md`) {
125
+ const result = { written: [], kept: [], collisions: [] };
126
+ const record = await readScaffold(root);
127
+ if (!(await exists(sourceFile))) {
128
+ throw new Error(`el agente "${id}" no existe en ${sourceFile}`);
129
+ }
130
+ await copyOnce(root, sourceFile, `.ai-hub/agents/${id}.md`, record, result, sourceKey);
131
+ await writeScaffold(root, record);
132
+ return result;
133
+ }
134
+ /** Copia las 4 rúbricas universales desde el paquete a `.ai-hub/rules/skill-quality/`. */
135
+ export async function materializeUniversalRules(root, preset, selectedRules = [...RUBRIC_RULE_IDS]) {
136
+ const result = { written: [], kept: [], collisions: [] };
137
+ const record = await readScaffold(root);
138
+ const core = await findPresetAsset(preset.chain, 'rules/core.md');
139
+ if (core === null) {
140
+ throw new Error(`el preset "${preset.id}" no declara rules/core.md`);
141
+ }
142
+ await copyOnce(root, core, '.ai-hub/rules/core.md', record, result, `@preset/${preset.id}/rules/core.md`);
143
+ const wanted = new Set(selectedRules.filter((id) => id.startsWith('skill-quality/')));
144
+ for (const id of RUBRIC_RULE_IDS) {
145
+ if (!wanted.has(id)) {
146
+ continue;
147
+ }
148
+ const source = path.join(ENGINE_RULES_DIR, `${id}.md`);
149
+ if (!(await exists(source))) {
150
+ throw new Error(`falta la rúbrica universal "${id}" en ${ENGINE_RULES_DIR}`);
151
+ }
152
+ await copyOnce(root, source, `.ai-hub/rules/${id}.md`, record, result, `@engine/rules/${id}.md`);
153
+ }
154
+ await writeScaffold(root, record);
155
+ return result;
156
+ }
157
+ /** Copia skills y agentes seleccionados del catálogo. */
158
+ export async function materializeCatalog(root, skillIds, agentIds) {
159
+ const result = { written: [], kept: [], collisions: [] };
160
+ const record = await readScaffold(root);
161
+ const copies = [];
162
+ for (const id of [...skillIds].sort()) {
163
+ const sourceDir = path.join(ENGINE_SKILLS_DIR, id);
164
+ if (!(await exists(sourceDir))) {
165
+ throw new Error(`la skill de catálogo "${id}" no existe en ${ENGINE_SKILLS_DIR}`);
166
+ }
167
+ for (const source of await walk(sourceDir)) {
168
+ const relative = path.relative(sourceDir, source).split(path.sep).join('/');
169
+ copies.push({
170
+ source,
171
+ destination: `.ai-hub/skills/${id}/${relative}`,
172
+ sourceKey: `@engine/skills/shared/${id}/${relative}`,
173
+ });
174
+ }
175
+ }
176
+ for (const id of [...agentIds].sort()) {
177
+ const source = path.join(ENGINE_AGENTS, `${id}.md`);
178
+ if (!(await exists(source))) {
179
+ throw new Error(`el agente de catálogo "${id}" no existe en ${ENGINE_AGENTS}`);
97
180
  }
181
+ copies.push({
182
+ source,
183
+ destination: `.ai-hub/agents/${id}.md`,
184
+ sourceKey: `@engine/agents/${id}.md`,
185
+ });
98
186
  }
187
+ await mapLimit(copies, 24, async (copy) => {
188
+ await copyOnce(root, copy.source, copy.destination, record, result, copy.sourceKey);
189
+ });
99
190
  await writeScaffold(root, record);
100
191
  return result;
101
192
  }
@@ -164,7 +164,11 @@ async function renderRules(root, config, out) {
164
164
  const isCore = area === 'core';
165
165
  for (const [tool, target] of Object.entries(config.rules.targets)) {
166
166
  if (target.shape === 'mdc') {
167
- putRendered(out, `${target.path}/${area}.mdc`, {
167
+ const preferred = `${target.path}/${area}.mdc`;
168
+ const destination = out.has(preferred)
169
+ ? `${target.path}/rule-${area}.mdc`
170
+ : preferred;
171
+ putRendered(out, destination, {
168
172
  content: withMarker(frontmatter({
169
173
  description: isCore ? 'Core project rules' : `Rules for ${area}`,
170
174
  globs: isCore ? '**' : `${area}/**`,
package/dist/sync/sync.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { mapLimit } from '../concurrency.js';
3
4
  import { isEngineCheckout } from '../source-checkout.js';
4
5
  import { loadSyncConfig, resolveSourcePath } from './config.js';
5
6
  import { normalize, renderAll } from './render.js';
@@ -54,16 +55,17 @@ export async function runSync(root, options) {
54
55
  await fs.rm(path.join(root, rel), { force: true });
55
56
  result.removed.push(rel);
56
57
  }
57
- for (const [rel, file] of sortedEntries(rendered)) {
58
+ const written = await mapLimit(sortedEntries(rendered), 24, async ([rel, file]) => {
58
59
  const abs = path.join(root, rel);
59
60
  const current = await readCopy(abs);
60
61
  if (current === file.content) {
61
- continue;
62
+ return null;
62
63
  }
63
64
  await fs.mkdir(path.dirname(abs), { recursive: true });
64
65
  await fs.writeFile(abs, file.content, 'utf8');
65
- result.written.push(rel);
66
- }
66
+ return rel;
67
+ });
68
+ result.written.push(...written.filter((rel) => rel !== null));
67
69
  await writeLock(root, nextLock);
68
70
  return result;
69
71
  }
@@ -73,26 +75,24 @@ function sourceDescription(source) {
73
75
  : `fuente: ${source}`;
74
76
  }
75
77
  async function checkCopies(root, rendered, previous) {
76
- const problems = [];
77
- for (const [rel, file] of sortedEntries(rendered)) {
78
+ const problems = await mapLimit(sortedEntries(rendered), 24, async ([rel, file]) => {
78
79
  const current = await readCopy(path.join(root, rel));
79
80
  if (current === null) {
80
- problems.push(`falta ${rel} (${sourceDescription(file.source)})`);
81
+ return `falta ${rel} (${sourceDescription(file.source)})`;
81
82
  }
82
- else if (current !== file.content) {
83
- const registered = previous?.files[rel];
84
- if (registered && sha256(current) !== registered.sha256) {
85
- const hint = file.source.startsWith('@engine/')
86
- ? 'viene del motor; actualiza el paquete'
87
- : `edita la fuente: ${file.source}`;
88
- problems.push(`editado a mano: ${rel} (${hint}; luego ejecuta ai-hub sync)`);
89
- }
90
- else {
91
- problems.push(`desactualizado: ${rel} (${sourceDescription(file.source)})`);
92
- }
83
+ if (current === file.content) {
84
+ return null;
93
85
  }
94
- }
95
- return problems;
86
+ const registered = previous?.files[rel];
87
+ if (registered && sha256(current) !== registered.sha256) {
88
+ const hint = file.source.startsWith('@engine/')
89
+ ? 'viene del motor; actualiza el paquete'
90
+ : `edita la fuente: ${file.source}`;
91
+ return `editado a mano: ${rel} (${hint}; luego ejecuta ai-hub sync)`;
92
+ }
93
+ return `desactualizado: ${rel} (${sourceDescription(file.source)})`;
94
+ });
95
+ return problems.filter((problem) => problem !== null);
96
96
  }
97
97
  export async function buildLock(root, rendered) {
98
98
  const sources = {};
package/manifest.json CHANGED
@@ -144,6 +144,15 @@
144
144
  "opencode",
145
145
  "pi"
146
146
  ],
147
+ "selection": {
148
+ "skills": "full",
149
+ "agents": "full",
150
+ "rules": "custom"
151
+ },
152
+ "catalog": {
153
+ "version": "0.2.0",
154
+ "hash": "f30e2590a4eb078ea520873661b5f10fb3cf9171d1422438c35aafd722abbb23"
155
+ },
147
156
  "agents": [
148
157
  "cpp-build-resolver",
149
158
  "cpp-reviewer",
@@ -375,7 +384,7 @@
375
384
  "worktrees"
376
385
  ],
377
386
  "rules": [
378
- "core",
387
+ "engine-core",
379
388
  "skill-quality/skill-behavior-rubric",
380
389
  "skill-quality/skill-harden-rubric",
381
390
  "skill-quality/skill-migration-brief",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsalazardev/ai-hub-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "author": "Daner Alejandro Salazar Colorado",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "manifest.json",
32
32
  "engine",
33
33
  "presets",
34
+ "rules",
34
35
  "agents",
35
36
  "skills",
36
37
  "LICENSE",
@@ -1,8 +1,8 @@
1
- id: angular-express
1
+ id: angular-express
2
2
  version: 1
3
3
  extends: generic-ts
4
4
  description: Angular frontend with an Express BFF, Vitest and an optional Dolibarr-style REST backend.
5
- # Sin detect: preset de cliente. Sólo se elige con --preset angular-express.
5
+ # Sin detect: preset de cliente. Sólo se elige con --preset angular-express.
6
6
  stack:
7
7
  languages:
8
8
  - typescript
@@ -51,5 +51,3 @@ skills:
51
51
  - onna-architecture-bff
52
52
  - onna-architecture-common
53
53
  - onna-design-system
54
- mcp:
55
- source: mcp-manifest.yaml
@@ -1,4 +1,4 @@
1
- id: base
1
+ id: base
2
2
  version: 1
3
3
  description: Minimal harness for any repository, regardless of language or framework.
4
4
  detect:
@@ -23,5 +23,3 @@ agents:
23
23
  publish: []
24
24
  skills:
25
25
  publish: []
26
- mcp:
27
- source: mcp-manifest.yaml
@@ -1,4 +1,4 @@
1
- id: generic-ts
1
+ id: generic-ts
2
2
  version: 1
3
3
  extends: base
4
4
  description: TypeScript repository with npm-compatible scripts and a standard test runner.
@@ -42,5 +42,3 @@ agents:
42
42
  - explorer-domain
43
43
  skills:
44
44
  publish: []
45
- mcp:
46
- source: mcp-manifest.yaml
@@ -1,4 +1,4 @@
1
- id: python
1
+ id: python
2
2
  version: 1
3
3
  extends: base
4
4
  description: Python project with pytest, mypy and ruff.
@@ -43,5 +43,3 @@ agents:
43
43
  - explorer-domain
44
44
  skills:
45
45
  publish: []
46
- mcp:
47
- source: mcp-manifest.yaml
@@ -0,0 +1,63 @@
1
+ # skill-behavior-rubric — how a skill's OUTPUT is graded (the behavioral gate)
2
+
3
+ Sibling of `skill-rubric.md`. That one scores the skill as a **document**; this one scores what
4
+ the skill **produces when run**. A skill ships only if BOTH gates are green.
5
+
6
+ ## How the behavioral eval works
7
+
8
+ For each `capability` scenario in the skill's `evals/cases.yaml`, the engine
9
+ (`scripts/skill-behavior-eval.workflow.js`) runs the scenario twice — once with the skill's
10
+ `SKILL.md` injected (**treatment**), once without it (**baseline**) — and an independent, blind
11
+ grader scores both outputs. The grader never learns which output used the skill; outputs are
12
+ presented as X and Y, their slot varied by scenario index parity so a "first output" bias can't
13
+ systematically favor the skill.
14
+
15
+ ## What the grader returns (per output)
16
+
17
+ 1. **must_include coverage.** For each `must_include` item: `satisfied` true/false **and a quoted
18
+ line** from the output that satisfies it. Evidence, not vibes. If unsure, mark unmet.
19
+ 2. **Quality, four axes 0-10:** `completeness`, `actionability`, `correctness`, `grounding`
20
+ (no invented facts / APIs / numbers).
21
+
22
+ The grader returns raw signals only. It does **not** compute the score — that keeps the number
23
+ deterministic and auditable.
24
+
25
+ ## Orientation signal (the brújula)
26
+
27
+ User-facing, conversational skills should keep the user oriented. When a skill's capability scenario ends a turn the user acts on, the grader expects the output to:
28
+
29
+ - **Situate** the user (where they are / what is built vs missing).
30
+ - **Teach the why**, in the register `technical_level` sets (technical, or with analogies) — the voice is `orient`'s.
31
+ - **Propose a next step phrased as a question** — never end in seco.
32
+
33
+ Encode this as `must_include` items in those skills' capability scenarios (see `skills/orient/evals/cases.yaml` for the canonical pattern). It scores through the existing coverage axis — no separate formula. Skills that own a purely mechanical task (lint, a single rename) are exempt and should not be penalized.
34
+
35
+ ## How signals become the /10 (computed by `scripts/lib/behavior-score.js`)
36
+
37
+ - Per output: `score = 0.6 × (satisfied / total × 10) + 0.4 × mean(quality axes)`.
38
+ Empty output → 0. No checklist → quality only.
39
+ - Per scenario: `absolute` = treatment's score; `delta` = treatment − baseline.
40
+ - Per skill: `absolute_score` = mean of absolutes; `lift` = mean of deltas.
41
+
42
+ ## The gate (both required)
43
+
44
+ - `absolute_score ≥ 8.5` — the skill genuinely produces a good result.
45
+ - `lift ≥ +1.0` — the skill measurably beats no-skill. `lift ≤ 0` is an automatic FAIL even at a
46
+ high absolute: the skill adds nothing the bare agent didn't already do.
47
+
48
+ ## Anti-gaming
49
+
50
+ - Independent, blind grader; default-skeptical on coverage.
51
+ - Errors fail closed: a scenario whose run or grade errored is dropped; if all drop, FAIL.
52
+ - A skill with no `capability` scenario cannot be behaviorally evaluated → FAIL until one exists.
53
+ - v1 uses a single grader per scenario. Upgrade path: N=3 graders, majority vote.
54
+
55
+ ## Running it
56
+
57
+ ```
58
+ # 1) execute + grade — invoke the Workflow tool with the engine script and the skill id:
59
+ # scriptPath: scripts/skill-behavior-eval.workflow.js args: "<skill-id>"
60
+ # capture the workflow's returned object to a JSON file (e.g. /tmp/<skill>-raw.json)
61
+ # 2) score + gate (exit 0 pass / 1 fail):
62
+ node scripts/skill-behavior-eval.js --score /tmp/<skill>-raw.json
63
+ ```
@@ -0,0 +1,102 @@
1
+ # skill-harden-rubric — how the fix-loop diagnoses and guards a fix
2
+
3
+ The `skill-harden` workflow turns a FAILING behavioral score into edits, without gaming the eval.
4
+ These are the rules its agents follow.
5
+
6
+ The three guards below — **diff judge**, **hold-out**, **Generalization gate** — are mandatory *and*
7
+ mechanised: `tests/harden-generalization-gate.test.js` asserts, for each of those three, that the
8
+ loop implements what this file promises. **Adding a fourth guard here means adding its pair to that
9
+ test**; a guard documented but unchecked gives the safety without the protection, which is the
10
+ failure mode this repo has now hit ten times (constitution P2).
11
+
12
+ ## Diagnosis (skill-fault vs eval-fault vs capability)
13
+
14
+ Given the failing `mustFix`, both A/B outputs, and the grader's per-item evidence, decide the FAULT:
15
+
16
+ - **skill** — the treatment output genuinely misses real capability (weak method, missing depth,
17
+ no concrete output). Fix the SKILL.md body and/or references/.
18
+ - **eval** — the failure is the eval's, not the skill's. Two known biases:
19
+ 1. **Self-describing scenario** — the `scenario` text enumerates its own `must_include`, so a
20
+ bare agent gets the same guidance the skill would give and the lift collapses artificially.
21
+ 2. **Phantom-context must_include** — an item demands workspace artifacts the isolated eval
22
+ agent cannot have (e.g. `user-profile.md`, a real sibling skill to delegate to), capping the
23
+ absolute unfairly.
24
+ - **capability** — the gap cannot be closed by words in a skill body. It needs an **executable
25
+ capability**: something that fires on a real execution event and returns to the agent information
26
+ it cannot obtain on its own — a hook, a script, a tool, a sub-agent. Advice that merely tells the
27
+ agent to remember something is **not** a capability: it is not anchored to any observable event, so
28
+ it can fire when it should not and stay silent when it should not.
29
+
30
+ This loop can only ever write **guidance** (SKILL.md, references/). So `capability` is not a fix
31
+ route, it is the honest exit: the loop edits nothing, names the missing capability and the surface it
32
+ would live on, and stops. Writing a paragraph that pretends to cover a missing capability raises the
33
+ score without the capability existing — the worst outcome available.
34
+
35
+ A `capability` verdict **must name** the capability and its surface. One that cannot is an excuse,
36
+ not a diagnosis → choose `skill`.
37
+
38
+ Default to **skill** when unsure: blaming the eval or the surface area is the easier, less honest path.
39
+
40
+ ## The Generalization gate (ex-ante — it binds before the edit is written)
41
+
42
+ This gate governs the **content** of every line the fixer writes, and it travels **in the fixer's own
43
+ instruction**, not in a review afterwards. Writing freely and reverting later burns the round, and the
44
+ loop only has two.
45
+
46
+ Everything added to a skill is global context that will apply to cases the skill has never seen. So:
47
+
48
+ - **Allowed:** a reusable criterion that carries its own **applicability condition** — something the
49
+ agent can evaluate on a case it has never encountered.
50
+ - **Banned outright:** identifiers or titles from the eval scenario; names of its files, symbols or
51
+ fixtures; rules that branch on its specific data; and reciting the finding as if it were a rule
52
+ ("the last round showed X needs Y" — that is an answer, not a criterion).
53
+
54
+ **Litmus test, before every line:** *would this still help on a case in this domain I have never
55
+ seen?* If no → rewrite it as a criterion, or drop it. Not knowledge → not in the skill.
56
+
57
+ Why ex-ante and not only as review: moving this constraint from after-the-edit to
58
+ before-the-edit is what buys generalization cheaply. The measured version of this result — plus the
59
+ warning that richer feedback **without** this gate makes held-out performance *worse* — is in
60
+ [HarnessCompass](https://arxiv.org/abs/2608.01918); the reasoning it drove is recorded in
61
+ `02-DOCS/wiki/sdd/specs/generalization-gate.md`.
62
+
63
+ ## Eval-fix guard (independent judge)
64
+
65
+ An eval edit ships ONLY if a judge certifies it corrects one of the two biases above and does NOT
66
+ lower the bar — i.e. it makes the scenario less self-describing or removes a phantom-context item,
67
+ but never deletes a legitimate quality criterion to make a weak skill pass. Rejected → treat as a
68
+ skill-fault this round.
69
+
70
+ ## Skill-fix guards (both required)
71
+
72
+ 1. **Diff judge.** Read the SKILL.md/references diff and check BOTH: does it add genuine capability
73
+ (method, decision rules, concrete guidance) rather than echoing the `must_include` wording into
74
+ the body? *and* does every added line survive the Generalization gate above? Fail either → revert
75
+ the edit; it is not a fix.
76
+ 2. **Hold-out.** Re-score the EDITED skill on a FRESH scenario from its domain that the fixer never
77
+ saw. A real improvement generalizes; a memorized one does not.
78
+ - The eval engine runs every scenario with **and** without the skill, so the fresh scenario
79
+ already carries its own lift. No pre-fix baseline is needed.
80
+ - The verdict is computed by `node scripts/skill-behavior-eval.js --holdout <raw.json>` — a
81
+ comparison of numbers, deterministic and reproducible, never an agent's opinion (P1). Exit 0
82
+ passes, exit 1 blocks.
83
+ - **Blocks on `lift <= 0`** (`regression`): on unseen work the edited skill does not beat the bare
84
+ agent, so the edit did not transfer. Also blocks when the fresh scenario could not be run or
85
+ graded (`indeterminate`) — failing closed, because an unverified edit is not a verified one.
86
+ - The bar is deliberately **not** the main gate's `lift >= 1.0`: one fresh scenario graded once is
87
+ noisy, so only the unambiguous signal acts. Upgrade path if it proves too noisy in practice:
88
+ N=3 fresh scenarios, majority.
89
+ - **A block reverts the edit.** The round produced nothing and is recorded with its reason.
90
+
91
+ ## Stopping & honesty
92
+
93
+ - Max 2 rounds. On give-up, report the honest final score and a recommendation — never a faked pass.
94
+ - **A main-gate pass whose last surviving edit failed the hold-out does not commit.** It is a fix
95
+ tuned to the cases it was measured against; the loop reverts it and reports why. Conservative on
96
+ purpose: a lost commit costs one re-run, a certified overfit costs the catalog.
97
+ - A `capability` verdict never commits either: it returns the missing capability and its surface, and
98
+ routes the work to `specify`.
99
+ - Every non-commit says which condition failed, in the returned result. A denial without its exit is
100
+ abandonment (P6).
101
+ - Persistent lift-fail at a high absolute → recommend deprecate/merge: the skill does not justify
102
+ its own existence.