@dzhechkov/harness-core 0.8.6 → 0.8.11

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 (164) hide show
  1. package/.dz-manifest.json +361 -97
  2. package/README.md +48 -1
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +12 -1
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/codex-invoke.d.ts +73 -0
  7. package/dist/codex-invoke.d.ts.map +1 -0
  8. package/dist/codex-invoke.js +80 -0
  9. package/dist/codex-invoke.js.map +1 -0
  10. package/dist/compounding.d.ts +54 -0
  11. package/dist/compounding.d.ts.map +1 -1
  12. package/dist/compounding.js +221 -1
  13. package/dist/compounding.js.map +1 -1
  14. package/dist/discrimination-gate.d.ts +63 -3
  15. package/dist/discrimination-gate.d.ts.map +1 -1
  16. package/dist/discrimination-gate.js +113 -16
  17. package/dist/discrimination-gate.js.map +1 -1
  18. package/dist/eta.d.ts +92 -0
  19. package/dist/eta.d.ts.map +1 -0
  20. package/dist/eta.js +488 -0
  21. package/dist/eta.js.map +1 -0
  22. package/dist/event-chain.d.ts +30 -0
  23. package/dist/event-chain.d.ts.map +1 -1
  24. package/dist/event-chain.js +24 -0
  25. package/dist/event-chain.js.map +1 -1
  26. package/dist/feature-adr-checkpoints.js +1 -1
  27. package/dist/feature-adr-decision-recall.d.ts +167 -0
  28. package/dist/feature-adr-decision-recall.d.ts.map +1 -0
  29. package/dist/feature-adr-decision-recall.js +519 -0
  30. package/dist/feature-adr-decision-recall.js.map +1 -0
  31. package/dist/feature-adr-landing.d.ts +37 -0
  32. package/dist/feature-adr-landing.d.ts.map +1 -0
  33. package/dist/feature-adr-landing.js +59 -0
  34. package/dist/feature-adr-landing.js.map +1 -0
  35. package/dist/feature-adr-routing.d.ts +2 -2
  36. package/dist/feature-adr-routing.d.ts.map +1 -1
  37. package/dist/feature-adr-routing.js +7 -11
  38. package/dist/feature-adr-routing.js.map +1 -1
  39. package/dist/guard-promotion.d.ts +41 -0
  40. package/dist/guard-promotion.d.ts.map +1 -1
  41. package/dist/guard-promotion.js +218 -4
  42. package/dist/guard-promotion.js.map +1 -1
  43. package/dist/guard-volume.d.ts +108 -0
  44. package/dist/guard-volume.d.ts.map +1 -0
  45. package/dist/guard-volume.js +536 -0
  46. package/dist/guard-volume.js.map +1 -0
  47. package/dist/guard.d.ts +17 -0
  48. package/dist/guard.d.ts.map +1 -1
  49. package/dist/guard.js +92 -4
  50. package/dist/guard.js.map +1 -1
  51. package/dist/index.d.ts +24 -7
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +19 -5
  54. package/dist/index.js.map +1 -1
  55. package/dist/integration-apply.d.ts +25 -0
  56. package/dist/integration-apply.d.ts.map +1 -0
  57. package/dist/integration-apply.js +299 -0
  58. package/dist/integration-apply.js.map +1 -0
  59. package/dist/integration-evidence.d.ts +46 -0
  60. package/dist/integration-evidence.d.ts.map +1 -0
  61. package/dist/integration-evidence.js +44 -0
  62. package/dist/integration-evidence.js.map +1 -0
  63. package/dist/integration-probe-worker.d.ts +22 -0
  64. package/dist/integration-probe-worker.d.ts.map +1 -0
  65. package/dist/integration-probe-worker.js +334 -0
  66. package/dist/integration-probe-worker.js.map +1 -0
  67. package/dist/integrations-verify.d.ts +60 -0
  68. package/dist/integrations-verify.d.ts.map +1 -0
  69. package/dist/integrations-verify.js +194 -0
  70. package/dist/integrations-verify.js.map +1 -0
  71. package/dist/lesson-generalization.d.ts +29 -0
  72. package/dist/lesson-generalization.d.ts.map +1 -0
  73. package/dist/lesson-generalization.js +84 -0
  74. package/dist/lesson-generalization.js.map +1 -0
  75. package/dist/mutation-gate.d.ts +39 -36
  76. package/dist/mutation-gate.d.ts.map +1 -1
  77. package/dist/mutation-gate.js +111 -5
  78. package/dist/mutation-gate.js.map +1 -1
  79. package/dist/operations.d.ts +27 -0
  80. package/dist/operations.d.ts.map +1 -1
  81. package/dist/operations.js +186 -7
  82. package/dist/operations.js.map +1 -1
  83. package/dist/patterns.d.ts +27 -1
  84. package/dist/patterns.d.ts.map +1 -1
  85. package/dist/patterns.js +211 -45
  86. package/dist/patterns.js.map +1 -1
  87. package/dist/plugin.d.ts.map +1 -1
  88. package/dist/plugin.js +27 -5
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/recommend.d.ts +4 -5
  91. package/dist/recommend.d.ts.map +1 -1
  92. package/dist/recommend.js +110 -45
  93. package/dist/recommend.js.map +1 -1
  94. package/dist/registry.d.ts +32 -1
  95. package/dist/registry.d.ts.map +1 -1
  96. package/dist/registry.js +165 -9
  97. package/dist/registry.js.map +1 -1
  98. package/dist/run-records.d.ts +3 -0
  99. package/dist/run-records.d.ts.map +1 -1
  100. package/dist/run-records.js +18 -0
  101. package/dist/run-records.js.map +1 -1
  102. package/dist/score.d.ts +95 -0
  103. package/dist/score.d.ts.map +1 -1
  104. package/dist/score.js +274 -2
  105. package/dist/score.js.map +1 -1
  106. package/dist/setup.d.ts.map +1 -1
  107. package/dist/setup.js +20 -17
  108. package/dist/setup.js.map +1 -1
  109. package/dist/skill-selection.d.ts +72 -0
  110. package/dist/skill-selection.d.ts.map +1 -0
  111. package/dist/skill-selection.js +76 -0
  112. package/dist/skill-selection.js.map +1 -0
  113. package/dist/stem.d.ts +12 -0
  114. package/dist/stem.d.ts.map +1 -0
  115. package/dist/stem.js +89 -0
  116. package/dist/stem.js.map +1 -0
  117. package/dist/target-integrations.d.ts +65 -0
  118. package/dist/target-integrations.d.ts.map +1 -0
  119. package/dist/target-integrations.js +152 -0
  120. package/dist/target-integrations.js.map +1 -0
  121. package/dist/telemetry-vocabulary.d.ts +7 -0
  122. package/dist/telemetry-vocabulary.d.ts.map +1 -1
  123. package/dist/telemetry-vocabulary.js +29 -0
  124. package/dist/telemetry-vocabulary.js.map +1 -1
  125. package/dist/vector-tier.d.ts +6 -1
  126. package/dist/vector-tier.d.ts.map +1 -1
  127. package/dist/vector-tier.js +32 -7
  128. package/dist/vector-tier.js.map +1 -1
  129. package/package.json +7 -6
  130. package/sbom.json +772 -112
  131. package/src/amendment-trace.ts +12 -1
  132. package/src/codex-invoke.ts +138 -0
  133. package/src/compounding.ts +300 -1
  134. package/src/discrimination-gate.ts +183 -19
  135. package/src/eta.ts +590 -0
  136. package/src/event-chain.ts +41 -0
  137. package/src/feature-adr-checkpoints.ts +1 -1
  138. package/src/feature-adr-decision-recall.ts +652 -0
  139. package/src/feature-adr-landing.ts +109 -0
  140. package/src/feature-adr-routing.ts +7 -11
  141. package/src/guard-promotion.ts +245 -4
  142. package/src/guard-volume.ts +752 -0
  143. package/src/guard.ts +110 -4
  144. package/src/index.ts +73 -6
  145. package/src/integration-apply.ts +332 -0
  146. package/src/integration-evidence.ts +89 -0
  147. package/src/integration-probe-worker.ts +310 -0
  148. package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
  149. package/src/integrations-verify.ts +258 -0
  150. package/src/lesson-generalization.ts +115 -0
  151. package/src/mutation-gate.ts +165 -5
  152. package/src/operations.ts +207 -7
  153. package/src/patterns.ts +252 -43
  154. package/src/plugin.ts +27 -5
  155. package/src/recommend.ts +116 -46
  156. package/src/registry.ts +144 -11
  157. package/src/run-records.ts +23 -0
  158. package/src/score.ts +361 -3
  159. package/src/setup.ts +20 -17
  160. package/src/skill-selection.ts +111 -0
  161. package/src/stem.ts +87 -0
  162. package/src/target-integrations.ts +225 -0
  163. package/src/telemetry-vocabulary.ts +36 -0
  164. package/src/vector-tier.ts +44 -14
package/src/recommend.ts CHANGED
@@ -17,6 +17,7 @@ import type { Registry, RegistryEntry } from './registry.js';
17
17
  import { pretrain } from './pretrain.js';
18
18
  import { computePatternBoost, loadPatterns, loadStoreRecords, readLearningConfig, readReinforcementState, recordToPattern } from './patterns.js';
19
19
  import { resolveLearningBackend } from './learning-backend.js';
20
+ import { stems } from './stem.js';
20
21
 
21
22
  /** A recommended skill with relevance score. */
22
23
  export interface SkillRecommendation {
@@ -63,41 +64,43 @@ export interface RecommendationReport {
63
64
  readonly commands: readonly CommandRecommendation[];
64
65
  readonly installCommand: string;
65
66
  readonly plan: readonly string[];
66
- /** Set when task was too generic and pretrain was used as fallback. */
67
+ /** Provenance of the topics used for ranking. */
68
+ readonly topicSource: 'task' | 'project-stack' | 'none';
69
+ /** @deprecated Compatibility alias; true exactly when topicSource is project-stack. */
67
70
  readonly pretrainFallback?: boolean;
68
71
  }
69
72
 
70
73
  /** Topic → keywords mapping for task decomposition. */
71
74
  const TOPIC_KEYWORDS: Record<string, string[]> = {
72
- 'api': ['api', 'rest', 'graphql', 'endpoint', 'openapi', 'swagger', 'http', 'grpc'],
73
- 'testing': ['test', 'testing', 'tdd', 'unit test', 'integration test', 'e2e', 'coverage', 'spec'],
74
- 'ci-cd': ['ci/cd', 'ci cd', 'pipeline', 'github actions', 'gitlab', 'jenkins', 'deploy', 'continuous integration', 'continuous delivery'],
75
- 'security': ['security', 'audit', 'vulnerability', 'owasp', 'injection', 'auth', 'codeql', 'sast'],
76
- 'database': ['database', 'migration', 'schema', 'sql', 'postgres', 'mysql', 'query', 'index'],
77
- 'kubernetes': ['kubernetes', 'k8s', 'helm', 'pod', 'deployment', 'container', 'cluster', 'service mesh'],
78
- 'docker': ['docker', 'compose', 'container', 'dockerfile', 'image', 'registry'],
79
- 'terraform': ['terraform', 'iac', 'infrastructure', 'cloud', 'aws', 'gcp', 'azure', 'provision'],
80
- 'monitoring': ['monitoring', 'observability', 'metrics', 'logs', 'traces', 'alerting', 'slo', 'grafana', 'prometheus'],
81
- 'incident': ['incident', 'outage', 'postmortem', 'oncall', 'pagerduty', 'sev1', 'downtime'],
82
- 'monorepo': ['monorepo', 'workspace', 'pnpm', 'turborepo', 'nx', 'changeset', 'lerna'],
83
- 'review': ['review', 'pull request', 'code review', 'merge', ' pr ', 'pr '],
84
- 'debug': ['debug', 'error', 'crash', 'stack trace', 'bug', 'fix', 'troubleshoot'],
85
- 'frontend': ['frontend', 'react', 'vue', 'component', 'ui', 'css', 'tailwind'],
86
- 'git': ['git', 'merge', 'rebase', 'conflict', 'branch', 'cherry-pick'],
87
- 'web3': ['web3', 'blockchain', 'defi', 'crypto', 'ethereum', 'solana', 'nft', 'token', 'swap', 'wallet'],
88
- 'search': ['search', 'brave', 'exa', 'web search', 'find information'],
89
- 'email': ['email', 'gmail', 'inbox', 'send email', 'mail'],
90
- 'productivity': ['sheets', 'calendar', 'tasks', 'todo', 'schedule', 'meeting', 'clickup', 'project management'],
91
- 'data': ['data', 'etl', 'elt', 'pipeline', 'transform', 'dbt', 'airflow', 'warehouse'],
92
- 'social': ['farcaster', 'reddit', 'social', 'community'],
93
- 'research': ['research', 'explore', 'casarium', 'competitor', 'market', 'analysis'],
94
- 'docs': ['documentation', 'docs', 'context7', 'library', 'reference'],
95
- 'scrape': ['scrape', 'crawl', 'extract', 'jina', 'content', 'markdown'],
96
- 'design-thinking': ['design thinking', 'user research', 'prototype', 'empathize', 'jtbd', 'jobs to be done', 'cjm', 'customer journey', 'vsm', 'value stream', 'hadi', 'lean canvas', 'usability', 'product discovery', 'mvp'],
97
- 'product': ['product', 'feature', 'roadmap', 'prd', 'requirements', 'sprint', 'backlog'],
98
- 'academic': ['thesis', 'dissertation', 'defense', 'ВКР', 'защита', 'ГЭК', 'рецензия', 'academic'],
99
- 'quality': ['quality', 'qa', 'qe', 'quality engineering', 'test strategy', 'coverage'],
100
- 'health': ['health', 'medical', 'clinical', 'diagnosis', 'drug', 'lab', 'patient'],
75
+ 'api': ['api', 'rest', 'graphql', 'endpoint', 'openapi', 'swagger', 'http', 'grpc', 'апи', 'эндпоинт', 'интерфейс api', 'http запрос'],
76
+ 'testing': ['test', 'testing', 'tdd', 'unit test', 'integration test', 'e2e', 'coverage', 'spec', 'тест', 'тестирование', 'автотест', 'покрытие тестами'],
77
+ 'ci-cd': ['ci/cd', 'ci cd', 'pipeline', 'github actions', 'gitlab', 'jenkins', 'deploy', 'continuous integration', 'continuous delivery', 'непрерывная интеграция', 'непрерывную интеграцию', 'непрерывная доставка', 'пайплайн сборки', 'автодеплой'],
78
+ 'security': ['security', 'audit', 'vulnerability', 'owasp', 'injection', 'auth', 'codeql', 'sast', 'безопасность', 'уязвимость', 'аудит безопасности', 'авторизация'],
79
+ 'database': ['database', 'migration', 'schema', 'sql', 'postgres', 'mysql', 'query', 'index', 'база данных', 'миграция базы', 'схема данных', 'запрос к базе'],
80
+ 'kubernetes': ['kubernetes', 'k8s', 'helm', 'pod', 'deployment', 'container', 'cluster', 'service mesh', 'кубернетес', 'кластер кубернетес', 'оркестрация контейнеров', 'хелм чарт'],
81
+ 'docker': ['docker', 'compose', 'container', 'dockerfile', 'image', 'registry', 'докер', 'докерфайл', 'образ контейнера', 'докер композ'],
82
+ 'terraform': ['terraform', 'iac', 'infrastructure', 'cloud', 'aws', 'gcp', 'azure', 'provision', 'терраформ', 'инфраструктура как код', 'облачная инфраструктура', 'провижининг'],
83
+ 'monitoring': ['monitoring', 'observability', 'metrics', 'logs', 'traces', 'alerting', 'slo', 'grafana', 'prometheus', 'мониторинг', 'наблюдаемость', 'метрика', 'трассировка', 'оповещение'],
84
+ 'incident': ['incident', 'outage', 'postmortem', 'oncall', 'pagerduty', 'sev1', 'downtime', 'инцидент', 'авария', 'простой сервиса', 'постмортем', 'дежурство'],
85
+ 'monorepo': ['monorepo', 'workspace', 'pnpm', 'turborepo', 'nx', 'changeset', 'lerna', 'монорепо', 'рабочее пространство', 'турборепо', 'чейнджсет'],
86
+ 'review': ['review', 'pull request', 'code review', 'merge', ' pr ', 'pr ', 'repo', 'repository review', 'ревью', 'код ревью', 'чужой репозиторий', 'разбор кода'],
87
+ 'debug': ['debug', 'error', 'crash', 'stack trace', 'bug', 'fix', 'troubleshoot', 'отладка', 'ошибка', 'баг', 'падение', 'не работает'],
88
+ 'frontend': ['frontend', 'react', 'vue', 'component', 'ui', 'css', 'tailwind', 'фронтенд', 'пользовательский интерфейс', 'компонент интерфейса', 'верстка'],
89
+ 'git': ['git', 'merge', 'rebase', 'conflict', 'branch', 'cherry-pick', 'гит', 'слияние веток', 'ребейз', 'конфликт git', 'ветка git'],
90
+ 'web3': ['web3', 'blockchain', 'defi', 'crypto', 'ethereum', 'solana', 'nft', 'token', 'swap', 'wallet', 'блокчейн', 'криптовалюта', 'эфириум', 'токен', 'криптокошелек'],
91
+ 'search': ['search', 'brave', 'exa', 'web search', 'find information', 'поиск', 'веб поиск', 'найти информацию', 'искать в интернете'],
92
+ 'email': ['email', 'gmail', 'inbox', 'send email', 'mail', 'электронная почта', 'электронную почту', 'отправить письмо', 'входящие письма', 'почтовый ящик'],
93
+ 'productivity': ['sheets', 'calendar', 'tasks', 'todo', 'schedule', 'meeting', 'clickup', 'project management', 'календарь', 'список дел', 'расписание', 'встреча', 'управление проектом'],
94
+ 'data': ['data', 'etl', 'elt', 'pipeline', 'transform', 'dbt', 'airflow', 'warehouse', 'обработка данных', 'хранилище данных', 'преобразование данных', 'конвейер данных'],
95
+ 'social': ['farcaster', 'reddit', 'social', 'community', 'соцсеть', 'сообщество', 'реддит', 'социальные сети'],
96
+ 'research': ['research', 'explore', 'casarium', 'competitor', 'market', 'analysis', 'исследование', 'рынок', 'конкурент', 'анализ рынка'],
97
+ 'docs': ['documentation', 'docs', 'context7', 'library', 'reference', 'документация', 'документацию', 'справочник', 'руководство', 'описание библиотеки'],
98
+ 'scrape': ['scrape', 'crawl', 'extract', 'jina', 'content', 'markdown', 'скрапинг', 'парсинг сайта', 'извлечь контент', 'обход сайта'],
99
+ 'design-thinking': ['design thinking', 'user research', 'prototype', 'empathize', 'jtbd', 'jobs to be done', 'cjm', 'customer journey', 'vsm', 'value stream', 'hadi', 'lean canvas', 'usability', 'product discovery', 'mvp', 'дизайн мышление', 'исследование пользователей', 'прототип продукта', 'путь клиента', 'ценностный поток'],
100
+ 'product': ['product', 'feature', 'roadmap', 'prd', 'requirements', 'sprint', 'backlog', 'продукт', 'функция продукта', 'дорожная карта', 'требования', 'спринт', 'бэклог'],
101
+ 'academic': ['thesis', 'dissertation', 'defense', 'ВКР', 'защита', 'ГЭК', 'рецензия', 'academic', 'диссертация', 'дипломная работа', 'научная работа'],
102
+ 'quality': ['quality', 'qa', 'qe', 'quality engineering', 'test strategy', 'coverage', 'качество', 'обеспечение качества', 'инженерия качества', 'стратегия тестирования'],
103
+ 'health': ['health', 'medical', 'clinical', 'diagnosis', 'drug', 'lab', 'patient', 'blood', 'blood test', 'lab results', 'анализ', 'кровь', 'здоровье', 'врач', 'диагноз', 'лаборатория', 'симптом'],
101
104
  };
102
105
 
103
106
  /** Command knowledge base — what each command does and when to use it. */
@@ -144,44 +147,84 @@ const TOOLKIT_KB: { name: string; npmPackage: string; install: string; descripti
144
147
  { name: 'skills-analyst-manual', npmPackage: '@dzhechkov/skills-analyst-manual', install: 'npx @dzhechkov/skills-analyst-manual init', description: '3-phase analyst composite (explore → research → solve)', topics: ['research', 'product'] },
145
148
  ];
146
149
 
150
+ const CYRILLIC_KEYWORD = /\p{Script=Cyrillic}/u;
151
+
152
+ function containsStemSequence(textStems: readonly string[], keywordStems: readonly string[]): boolean {
153
+ if (keywordStems.length === 0 || keywordStems.length > textStems.length) return false;
154
+ return textStems.some((_, start) => keywordStems.every(
155
+ (keywordStem, offset) => textStems[start + offset] === keywordStem,
156
+ ));
157
+ }
158
+
159
+ function keywordMatches(lowerText: string, textStems: readonly string[], keyword: string): boolean {
160
+ const normalizedKeyword = keyword.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
161
+ // Preserve the old raw-substring behavior for the pre-existing Latin dictionary,
162
+ // including deliberately padded ` pr `. New Cyrillic rows use token/stem equality,
163
+ // so a word such as `протест` cannot accidentally activate the `тест` topic.
164
+ const rawMatch = !CYRILLIC_KEYWORD.test(normalizedKeyword) && lowerText.includes(normalizedKeyword);
165
+ return rawMatch || containsStemSequence(textStems, stems(normalizedKeyword));
166
+ }
167
+
147
168
  /** Extract topics from a task description. */
148
169
  function extractTopics(task: string): string[] {
149
- const lower = task.toLowerCase();
170
+ const lower = task.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
171
+ const taskStems = stems(task);
150
172
  const matched: string[] = [];
151
173
  for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS)) {
152
174
  for (const kw of keywords) {
153
- if (lower.includes(kw)) {
175
+ if (keywordMatches(lower, taskStems, kw)) {
154
176
  matched.push(topic);
155
177
  break;
156
178
  }
157
179
  }
158
180
  }
159
- return matched.length > 0 ? matched : ['general'];
181
+ return matched;
160
182
  }
161
183
 
162
184
  /** Score a skill against extracted topics. */
163
- function scoreSkill(entry: RegistryEntry, topics: string[]): number {
164
- const lower = `${entry.id} ${entry.description} ${entry.category}`.toLowerCase();
185
+ function scoreSkill(entry: RegistryEntry, topics: string[], task?: string): number {
186
+ const text = `${entry.id} ${entry.description} ${entry.category}`;
187
+ const lower = text.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
188
+ const textStems = stems(text);
165
189
  let score = 0;
166
190
  for (const topic of topics) {
167
191
  const keywords = TOPIC_KEYWORDS[topic] ?? [topic];
168
192
  for (const kw of keywords) {
169
- if (lower.includes(kw)) { score += 10; break; }
193
+ if (keywordMatches(lower, textStems, kw)) { score += 10; break; }
194
+ }
195
+ }
196
+ // Topic points alone are FLAT: every skill of a matching topic scores exactly 10, so dozens tie
197
+ // and the top-N cut falls arbitrarily among them. Harmless while the catalogue was small; the
198
+ // moment it grew from 202 to 249 skills (layout fix, 2026-09-01) the right answer started losing
199
+ // ties to same-topic neighbours. A small tie-break by ACTUAL overlap with the asked task keeps
200
+ // the topic signal dominant (10 per topic) while letting the skill the user literally described
201
+ // rise above its topic-mates: an id word is worth more than a description word, because an id
202
+ // match is rarely accidental.
203
+ if (task !== undefined && task !== '') {
204
+ const words = [...new Set(task.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [])];
205
+ const id = entry.id.toLowerCase();
206
+ const desc = String(entry.description ?? '').toLowerCase();
207
+ const idStems = new Set(stems(entry.id));
208
+ const descStems = new Set(stems(String(entry.description ?? '')));
209
+ let overlap = 0;
210
+ for (const w of words) {
211
+ const wordStem = stems(w)[0];
212
+ if (id.includes(w) || (wordStem !== undefined && idStems.has(wordStem))) overlap += 3;
213
+ else if (desc.includes(w) || (wordStem !== undefined && descStems.has(wordStem))) overlap += 1;
170
214
  }
215
+ // Capped below one topic point so a tie-break can never outrank a genuine topic match.
216
+ score += Math.min(overlap, 9);
171
217
  }
172
218
  return score;
173
219
  }
174
220
 
175
- /** Generate recommendation from a task and registry.
176
- * When task is too generic (only 'general' topic), falls back to pretrain
177
- * to analyze the actual project and recommend based on tech stack.
178
- */
221
+ /** Generate recommendations from task topics, or visibly-labelled project-stack topics on a miss. */
179
222
  export function recommend(task: string, registry: Registry, projectRoot?: string): RecommendationReport {
180
223
  let topics = extractTopics(task);
181
- let pretrainFallback = false;
224
+ let topicSource: RecommendationReport['topicSource'] = topics.length > 0 ? 'task' : 'none';
182
225
 
183
- // Fallback: if task is too generic, use pretrain to detect project stack
184
- if (topics.length === 1 && topics[0] === 'general' && projectRoot) {
226
+ // Fallback: if no task keyword matched, use pretrain only as explicitly stack-derived advice.
227
+ if (topics.length === 0 && projectRoot) {
185
228
  const analysis = pretrain(projectRoot);
186
229
  const pretrainTopics: string[] = [];
187
230
  const techNames = analysis.techs.map((t) => t.name.toLowerCase());
@@ -195,9 +238,25 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
195
238
  if (analysis.hasTests) pretrainTopics.push('testing');
196
239
  if (pretrainTopics.length > 0) {
197
240
  topics = [...new Set(pretrainTopics)];
198
- pretrainFallback = true;
241
+ topicSource = 'project-stack';
199
242
  }
200
243
  }
244
+ const pretrainFallback = topicSource === 'project-stack';
245
+
246
+ if (topicSource === 'none') {
247
+ return {
248
+ task,
249
+ topics: [],
250
+ skills: [],
251
+ presets: [],
252
+ toolkits: [],
253
+ commands: [],
254
+ installCommand: '',
255
+ plan: [],
256
+ topicSource,
257
+ pretrainFallback,
258
+ };
259
+ }
201
260
 
202
261
  // Learned-pattern read-back (audit #2): reward-rank taught patterns into a
203
262
  // bounded, monotonic boost. Gated on the rollout flag; when memory is empty or
@@ -223,7 +282,7 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
223
282
  const scored = registry.entries
224
283
  .map((e) => ({
225
284
  entry: e,
226
- score: scoreSkill(e, topics) + (patterns.length ? boostFor(e) : 0),
285
+ score: scoreSkill(e, topics, task) + (patterns.length ? boostFor(e) : 0),
227
286
  }))
228
287
  .filter((s) => s.score > 0)
229
288
  .sort((a, b) => b.score - a.score);
@@ -309,5 +368,16 @@ export function recommend(task: string, registry: Registry, projectRoot?: string
309
368
  }
310
369
  plan.push(`${plan.length + 1}. Use your agent normally — skills auto-activate on matching tasks`);
311
370
 
312
- return { task, topics, skills, presets, toolkits, commands, installCommand, plan, pretrainFallback };
371
+ return {
372
+ task,
373
+ topics,
374
+ skills,
375
+ presets,
376
+ toolkits,
377
+ commands,
378
+ installCommand,
379
+ plan,
380
+ topicSource,
381
+ pretrainFallback,
382
+ };
313
383
  }
package/src/registry.ts CHANGED
@@ -8,9 +8,11 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync, type Dirent } from 'node:fs';
11
- import { basename, dirname, join, resolve } from 'node:path';
11
+ import { basename, dirname, join, relative, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
 
14
+ import { stems } from './stem.js';
15
+
14
16
  /**
15
17
  * Resolve every `@dzhechkov` base directory that may hold `skills-*` packs, for a given
16
18
  * working directory. Ordered by precedence (first wins on pack-name collision):
@@ -77,6 +79,37 @@ function readSkillDiscoveryConfig(cwd: string): { skillScopes: string[]; skillDi
77
79
  }
78
80
 
79
81
  /** List `skills-*` pack directories across all base dirs, de-duplicated by pack name (first wins). */
82
+ /**
83
+ * Does this directory actually carry skills? Answers by LOOKING, so a pack is catalogued for what it
84
+ * contains rather than for how it is named. Bounded on purpose: only the three layouts real packs
85
+ * use (`skills/<id>/SKILL.md`, `<pack>/<id>/SKILL.md` for a single-skill pack, and a bare
86
+ * `SKILL.md`), never a full-tree walk — an unbounded scan over `node_modules` would cost more than
87
+ * the catalogue it builds. Templates are excluded: a template is a stamp for making skills, not an
88
+ * installed skill, and counting it would list the same name twice.
89
+ */
90
+ function packCarriesSkills(dir: string): boolean {
91
+ const hasSkillMd = (d: string): boolean => {
92
+ try {
93
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
94
+ if (entry.name === 'templates' || entry.name === 'node_modules') continue;
95
+ if (entry.isDirectory() && existsSync(join(d, entry.name, 'SKILL.md'))) return true;
96
+ }
97
+ } catch { /* unreadable dir is simply not a skill carrier */ }
98
+ return false;
99
+ };
100
+ try {
101
+ if (existsSync(join(dir, 'SKILL.md'))) return true;
102
+ if (hasSkillMd(join(dir, 'skills'))) return true;
103
+ // A template pack ships the skills it will roll out into the user's project. From the
104
+ // catalogue's point of view those skills EXIST — `trip-planner` and `presentation-storyteller`
105
+ // are installable answers to a task — so hiding them makes the advisor deny a real capability.
106
+ if (hasSkillMd(join(dir, 'templates', '.claude', 'skills'))) return true;
107
+ return hasSkillMd(dir);
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
80
113
  export function discoverSkillPackDirs(cwd: string): { pack: string; dir: string }[] {
81
114
  const seen = new Set<string>();
82
115
  const out: { pack: string; dir: string }[] = [];
@@ -98,6 +131,48 @@ export function discoverSkillPackDirs(cwd: string): { pack: string; dir: string
98
131
  return out;
99
132
  }
100
133
 
134
+ /**
135
+ * Every directory that CARRIES skills a user can invoke — the catalogue question.
136
+ *
137
+ * A third enumerator on purpose, by the same ADR-001 reasoning that split signature verification
138
+ * from pack discovery. `discoverSkillPackDirs` answers "which SKILL PACKS are here?" and the
139
+ * `skills-` prefix is the right answer to THAT — AM-1 pins it, and widening it would let a plugin
140
+ * be counted as a pack. This function asks something else: "what can the assistant actually offer
141
+ * the user?" — and there the prefix is wrong.
142
+ *
143
+ * MEASURED 2026-09-01: 41 skill names existed on disk and were absent from `dz registry` —
144
+ * every medical skill of `health-advisor` (31 SKILL.md), plus keysarium, p-replicator,
145
+ * design-thinking, trip-planner, evidence-wiki. That gap is not "fewer results": `skill-advisor`
146
+ * must check a name against this catalogue and treat an unlisted one as a fabrication, so an
147
+ * invisible skill turns a hallucination guard into a ban on naming the right answer — asked about
148
+ * blood tests, a live session answered "there is no medical skill in the DZ catalogue" with 31 of
149
+ * them on disk. An authoritative denial of existence is worse than an empty result: it closes the
150
+ * question.
151
+ *
152
+ * The double-count AM-1 guards against is handled where it belongs — `buildRegistry` dedupes by
153
+ * skill id, so a skill reachable through both its canon and a plugin is listed once.
154
+ */
155
+ export function discoverSkillCarryingDirs(cwd: string): { pack: string; dir: string }[] {
156
+ const seen = new Set<string>();
157
+ const out: { pack: string; dir: string }[] = [];
158
+ for (const { pack, dir } of discoverSkillPackDirs(cwd)) {
159
+ if (!seen.has(pack)) { seen.add(pack); out.push({ pack, dir }); }
160
+ }
161
+ for (const base of skillPackBaseDirs(cwd)) {
162
+ let entries: Dirent[];
163
+ try { entries = readdirSync(base, { withFileTypes: true }); } catch { continue; }
164
+ for (const e of entries) {
165
+ if (seen.has(e.name)) continue;
166
+ const dir = join(base, e.name);
167
+ const isDir = e.isDirectory() || (e.isSymbolicLink() && (() => { try { return statSync(dir).isDirectory(); } catch { return false; } })());
168
+ if (!isDir || !packCarriesSkills(dir)) continue;
169
+ seen.add(e.name);
170
+ out.push({ pack: e.name, dir });
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
101
176
  /**
102
177
  * Every pack whose SIGNATURE should be checked: the skill packs above, PLUS any directory in the same
103
178
  * base dirs that carries a `.dz-manifest.json`, whatever it is called.
@@ -191,6 +266,13 @@ export interface RegistryEntry {
191
266
  readonly hasEvals: boolean;
192
267
  readonly lineCount: number;
193
268
  readonly category: string;
269
+ /**
270
+ * Repo-relative path of the skill directory. Stored rather than reconstructed: a skill lives in
271
+ * one of three layouts (pack root, `skills/`, `templates/.claude/skills/`), so `<pack>/<id>` is
272
+ * a guess that silently breaks for two of them — the plugin generator built exactly that guess
273
+ * and produced unresolvable paths the moment the catalogue learned the other layouts.
274
+ */
275
+ readonly path?: string;
194
276
  }
195
277
 
196
278
  /** The full registry. */
@@ -283,6 +365,19 @@ function categoryFromPack(pack: string): string {
283
365
  if (pack.includes('book') || pack.includes('12factor')) return 'knowledge';
284
366
  // Course/tutorial manufacturing (skills-tutorial-factory: package → Head-First edu-site course).
285
367
  if (pack.includes('tutorial')) return 'learning';
368
+ // Packs that carry skills without the `skills-` prefix, catalogued since 2026-09-01. Each needs a
369
+ // category or it lands in `other`, and a test rightly forbids that bucket: an uncategorised skill
370
+ // cannot be filtered for, which is half of being findable.
371
+ if (pack === 'keysarium' || pack.includes('evidence-wiki')) return 'research';
372
+ // feature-adr ships its skills as templates, so they were invisible until the layout fix and the
373
+ // pack never needed a category before. The pipeline that manufactures features is meta-work.
374
+ if (pack === 'p-replicator' || pack.includes('loop-designer') || pack.includes('feature-adr')) return 'meta';
375
+ if (pack === 'design-thinking') return 'design';
376
+ if (pack === 'trip-planner') return 'personal';
377
+ // Packs whose skills live in `templates/` and were therefore never read until the layout fix.
378
+ // Each needs a home or it lands in `other`, which a test rightly forbids.
379
+ if (pack.includes('analyst-manual')) return 'product';
380
+ if (pack.includes('edu-site') || pack.includes('transcript-site')) return 'learning';
286
381
  return 'other';
287
382
  }
288
383
 
@@ -294,24 +389,45 @@ function categoryFromPack(pack: string): string {
294
389
  */
295
390
  export function buildRegistry(cwd: string): Registry {
296
391
  const entries: RegistryEntry[] = [];
297
- const packs = discoverSkillPackDirs(cwd);
392
+ const packs = discoverSkillCarryingDirs(cwd);
393
+
394
+ // A pack keeps its skills in one of three shapes, and reading only the first made 41 real skills
395
+ // invisible (MEASURED 2026-09-01): the pack root (`skills-*` packs), a `skills/` subdirectory
396
+ // (health-advisor and friends), and `templates/.claude/skills/` for packs that roll their skills
397
+ // out into the user's project. The catalogue answers "what can I use", so all three count.
398
+ const SKILL_LAYOUTS = [[], ['skills'], ['templates', '.claude', 'skills']] as const;
399
+ const seenSkillIds = new Set<string>();
298
400
 
299
401
  for (const { pack, dir: packDir } of packs) {
300
- const skillDirs = readdirSync(packDir, { withFileTypes: true })
301
- .filter((e) => e.isDirectory() && existsSync(join(packDir, e.name, 'SKILL.md')));
402
+ const found: { name: string; root: string }[] = [];
403
+ for (const layout of SKILL_LAYOUTS) {
404
+ const root = layout.length === 0 ? packDir : join(packDir, ...layout);
405
+ if (!existsSync(root)) continue;
406
+ try {
407
+ for (const e of readdirSync(root, { withFileTypes: true })) {
408
+ if (e.isDirectory() && existsSync(join(root, e.name, 'SKILL.md'))) found.push({ name: e.name, root });
409
+ }
410
+ } catch { /* an unreadable layout contributes nothing; the others still count */ }
411
+ }
302
412
 
303
- for (const skill of skillDirs) {
304
- const skillMdPath = join(packDir, skill.name, 'SKILL.md');
413
+ for (const skill of found) {
414
+ // One skill id can ship in several packs (frontend-design is bundled by three). The catalogue
415
+ // answers "is this available", not "in how many packs" — so the first sighting wins and the
416
+ // list stays a list of capabilities rather than of copies.
417
+ if (seenSkillIds.has(skill.name)) continue;
418
+ seenSkillIds.add(skill.name);
419
+ const skillMdPath = join(skill.root, skill.name, 'SKILL.md');
305
420
  const content = readFileSync(skillMdPath, 'utf-8');
306
421
  const { description, trustTier } = extractFrontmatter(content);
307
422
 
308
423
  entries.push({
309
424
  id: skill.name,
310
425
  pack,
426
+ path: relative(cwd, join(skill.root, skill.name)) || join(skill.root, skill.name),
311
427
  description,
312
428
  trustTier,
313
- hasSchema: existsSync(join(packDir, skill.name, 'schemas', 'output.json')),
314
- hasEvals: existsSync(join(packDir, skill.name, 'evals')),
429
+ hasSchema: existsSync(join(skill.root, skill.name, 'schemas', 'output.json')),
430
+ hasEvals: existsSync(join(skill.root, skill.name, 'evals')),
315
431
  lineCount: content.split('\n').length,
316
432
  category: categoryFromPack(pack),
317
433
  });
@@ -332,9 +448,26 @@ export function buildRegistry(cwd: string): Registry {
332
448
  /** Search registry by query (matches id and description, case-insensitive). */
333
449
  export function searchRegistry(registry: Registry, query: string): readonly RegistryEntry[] {
334
450
  const q = query.toLowerCase();
335
- return registry.entries.filter(
336
- (e) => e.id.toLowerCase().includes(q) || e.description.toLowerCase().includes(q) || e.category.includes(q),
337
- );
451
+ const queryStems = stems(query);
452
+ return registry.entries.filter((e) => {
453
+ const indexedText = `${e.id} ${e.description} ${e.category}`;
454
+ const rawMatch = e.id.toLowerCase().includes(q)
455
+ || e.description.toLowerCase().includes(q)
456
+ || e.category.toLowerCase().includes(q);
457
+ if (rawMatch) return true;
458
+
459
+ const indexedStems = new Set(stems(indexedText));
460
+ const equalStemMatch = queryStems.length > 0 && queryStems.every((stem) => indexedStems.has(stem));
461
+ if (equalStemMatch) return true;
462
+
463
+ // A singular raw query can already match its root inside a longer token (`анализ`
464
+ // inside `проанализируй`). Its inflected twin must inherit that existing hit or the
465
+ // preserved raw tier makes parity impossible. Limit this compatibility leg to one
466
+ // long stem; short common prefixes stay on exact stem equality.
467
+ const singleStem = queryStems.length === 1 ? queryStems[0] : undefined;
468
+ const foldedIndex = indexedText.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
469
+ return singleStem !== undefined && singleStem.length >= 5 && foldedIndex.includes(singleStem);
470
+ });
338
471
  }
339
472
 
340
473
  /** Filter registry by category. */
@@ -94,6 +94,11 @@ function shapeMismatch(kind: RecordKind, payload: Record<string, unknown>): stri
94
94
  return null;
95
95
  }
96
96
 
97
+ /** A runner id is missing when absent or blank — the same gap rule the date stamp uses. */
98
+ function isRunnerGap(v: unknown): boolean {
99
+ return v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
100
+ }
101
+
97
102
  export function decideRecordWrite(input: {
98
103
  kind: RecordKind;
99
104
  /** The raw `--row` / `--pair` argument, exactly as the caller passed it. */
@@ -109,6 +114,9 @@ export function decideRecordWrite(input: {
109
114
  targetHasPair?: boolean;
110
115
  /** Stamped INTO the object before serialising — never rewritten in the shell afterwards (FR-7). */
111
116
  timestamp?: string | null;
117
+ /** Who ran it. Supplied by the CALLER, which lives outside the workflow sandbox and can see the
118
+ * host; absent stays absent (see the stamping comment below). */
119
+ runnerId?: string | null;
112
120
  maxChars?: number;
113
121
  }): RecordDecision {
114
122
  const { kind, payloadRaw, stage } = input;
@@ -175,6 +183,21 @@ export function decideRecordWrite(input: {
175
183
  if (kind === 'training-pair' && isGap(stamped['ts'])) stamped['ts'] = input.timestamp;
176
184
  }
177
185
 
186
+ // WHO ran this. Stamped HERE and nowhere else, for a structural reason: the workflow lives in a
187
+ // sandbox with no host, no process and no clock, so it cannot name its own runner — but this
188
+ // command runs outside that sandbox and can. Same seam that already stamps the date.
189
+ //
190
+ // The field answers a DIFFERENT question from the zombie-preflight predicate (backlog 4a727ac6):
191
+ // that one asks "is this job's PARENT still alive", this one asks "which runner produced this
192
+ // row". Complementary, not duplicate — a future run index joins them, and neither can answer for
193
+ // the other. Absent identity stays ABSENT: an unknown runner is never invented as 'unknown',
194
+ // because a fabricated identity is worse than a missing one for anything that later joins on it.
195
+ // A blank supplied id is a gap too: `' '` sneaking in as a value would join later as a distinct
196
+ // runner made of spaces — the same class of harm as inventing 'unknown'.
197
+ if (kind === 'ledger' && isRunnerGap(stamped['runnerId']) && !isRunnerGap(input.runnerId)) {
198
+ stamped['runnerId'] = (input.runnerId as string).trim();
199
+ }
200
+
178
201
  let line: string;
179
202
  try {
180
203
  line = JSON.stringify(stamped);