@shahimat/harness 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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ npx -y @shahimat/harness@<version> <команда> --root .
34
34
  ## `context/harness.yml`
35
35
 
36
36
  ```yaml
37
- version: 0.1.0
37
+ version: 0.2.0
38
38
  adapters: [claude-code, codex]
39
39
  checks:
40
40
  changelog:
@@ -229,36 +229,55 @@ class Collector {
229
229
  this.report(manifest, `index_layers: dir «${String(dir)}» — нужен путь внутри проекта`);
230
230
  continue;
231
231
  }
232
+ const format = (isRecord(item) ? item['format'] : undefined) ?? 'md';
233
+ if (format !== 'md' && format !== 'yml') {
234
+ this.report(manifest, `index_layers: format «${String(format)}» — md или yml`);
235
+ continue;
236
+ }
232
237
  const taken = layers.find((l) => l.section === section);
233
238
  if (taken !== undefined) {
234
239
  this.report(manifest, `index_layers: section «${section}» уже объявлена в ${taken.manifest}`);
235
240
  continue;
236
241
  }
237
- layers.push({ section, dir: dir.replace(/\/+$/, ''), manifest });
242
+ layers.push({ section, dir: dir.replace(/\/+$/, ''), format, manifest });
238
243
  }
239
244
  }
240
245
  return layers;
241
246
  }
242
247
  /**
243
- * Файл слоя модуля — markdown с frontmatter `id`, `namespace`; строка
244
- * индекса — первый `# …`. `id` = путь внутри слоя без `.md`, `/` → `-`.
248
+ * Файл слоя модуля: `md` — frontmatter `id`, `namespace`, строка индекса —
249
+ * первый `# …`; `yml` — поля `id`, `namespace`, `summary`. `id` = путь
250
+ * внутри слоя без расширения, `/` → `-`.
245
251
  */
246
252
  async collectLayer(layer) {
247
- for (const file of await listFiles(this.root, layer.dir, '.md')) {
253
+ const ext = layer.format === 'md' ? '.md' : '.yml';
254
+ for (const file of await listFiles(this.root, layer.dir, ext)) {
248
255
  let data;
249
- const text = await readFile(path.join(this.root, file), 'utf8');
250
- try {
251
- data = parseFrontmatter(text);
252
- }
253
- catch (error) {
254
- this.report(file, `невалидный frontmatter: ${error.message.split('\n')[0]}`);
255
- continue;
256
+ let title;
257
+ if (layer.format === 'yml') {
258
+ data = await this.readYaml(file);
259
+ if (data === null)
260
+ continue;
261
+ title = this.oneLine(file, data, 'summary');
256
262
  }
257
- if (data === null) {
258
- this.report(file, 'нет frontmatter (id, namespace)');
259
- continue;
263
+ else {
264
+ const text = await readFile(path.join(this.root, file), 'utf8');
265
+ try {
266
+ data = parseFrontmatter(text);
267
+ }
268
+ catch (error) {
269
+ this.report(file, `невалидный frontmatter: ${error.message.split('\n')[0]}`);
270
+ continue;
271
+ }
272
+ if (data === null) {
273
+ this.report(file, 'нет frontmatter (id, namespace)');
274
+ continue;
275
+ }
276
+ title = firstHeading(text);
277
+ if (title === null)
278
+ this.report(file, 'нет заголовка «# …» (одна строка для индекса)');
260
279
  }
261
- const expected = file.slice(layer.dir.length + 1, -'.md'.length).split('/').join('-');
280
+ const expected = file.slice(layer.dir.length + 1, -ext.length).split('/').join('-');
262
281
  const id = data['id'];
263
282
  if (typeof id !== 'string' || id === '') {
264
283
  this.report(file, 'нет поля id');
@@ -267,9 +286,6 @@ class Collector {
267
286
  if (id !== expected)
268
287
  this.report(file, `id «${id}» не совпадает с путём «${expected}»`);
269
288
  const ns = this.checkNamespace(file, data);
270
- const title = firstHeading(text);
271
- if (title === null)
272
- this.report(file, 'нет заголовка «# …» (одна строка для индекса)');
273
289
  if (ns === null)
274
290
  continue;
275
291
  const sections = this.namespace(ns).layers;
@@ -8,6 +8,8 @@ export interface IndexEntry {
8
8
  applicableTo: string[];
9
9
  patterns: string[];
10
10
  title?: string;
11
+ /** Чем заменён устаревший промпт: ключ или путь замены. */
12
+ deprecated?: string;
11
13
  /** Блок `feature` модуля-возможности — то, что нужно для чеклиста. */
12
14
  feature?: FeatureInfo;
13
15
  /** Файлы skill: `path` от корня репозитория и SHA-256 каждого. */
@@ -1,19 +1,21 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { stringify } from 'yaml';
4
+ import { parse, stringify } from 'yaml';
5
5
  import { normalizeEol, parseFrontmatter } from './frontmatter.js';
6
6
  export const INDEX_PATH = 'prompts/index.yml';
7
7
  const SOURCE_DIRS = ['prompts', 'patterns'];
8
8
  const HEADER = '# generated by harness index build — do not edit manually';
9
9
  const SKILLS_DIR = 'skills';
10
10
  const AGENTS_DIR = 'agents';
11
+ const PRESETS_DIR = 'presets';
11
12
  const GROUPS = [
12
13
  { kind: 'contract', label: 'contracts' },
13
14
  { kind: 'pattern', label: 'patterns' },
14
15
  { kind: 'module', label: 'modules' },
15
16
  { kind: 'skill', label: 'skills' },
16
17
  { kind: 'agent', label: 'agents' },
18
+ { kind: 'preset', label: 'presets' },
17
19
  { kind: 'prompt', label: 'prompts' },
18
20
  ];
19
21
  /** Поля `feature` во frontmatter; `asks` читается из самого модуля, в индекс не идёт. */
@@ -128,6 +130,9 @@ export async function collectEntries(root) {
128
130
  const title = oneLine(fm?.['title'], 'title', file);
129
131
  if (title !== undefined)
130
132
  entry.title = title;
133
+ const deprecated = oneLine(fm?.['deprecated'], 'deprecated', file);
134
+ if (deprecated !== undefined)
135
+ entry.deprecated = deprecated;
131
136
  const feature = parseFeature(fm?.['feature'], kind, file);
132
137
  if (feature !== undefined)
133
138
  entry.feature = feature;
@@ -135,6 +140,7 @@ export async function collectEntries(root) {
135
140
  }
136
141
  entries.push(...(await collectSkills(root)), ...(await collectAgents(root)));
137
142
  checkFeatureRefs(entries);
143
+ entries.push(...(await collectPresets(root, entries)));
138
144
  return { entries, skipped };
139
145
  }
140
146
  async function listNames(abs) {
@@ -191,6 +197,53 @@ async function collectSkills(root) {
191
197
  }
192
198
  return entries;
193
199
  }
200
+ /**
201
+ * Пресет — `presets/<key>.yml`: готовый набор возможностей для типа
202
+ * проекта. Модули пресета должны быть в индексе и применимы к его контракту.
203
+ */
204
+ async function collectPresets(root, known) {
205
+ const entries = [];
206
+ for (const entry of await listNames(path.join(root, PRESETS_DIR))) {
207
+ if (!entry.endsWith('.yml'))
208
+ continue;
209
+ const file = `${PRESETS_DIR}/${entry}`;
210
+ const key = entry.slice(0, -4);
211
+ const content = await readFile(path.join(root, file), 'utf8');
212
+ let data;
213
+ try {
214
+ data = parse(content);
215
+ }
216
+ catch (error) {
217
+ throw new Error(`${file}: невалидный YAML: ${error.message.split('\n')[0]}`, { cause: error });
218
+ }
219
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
220
+ throw new Error(`${file}: корень должен быть объектом`);
221
+ const preset = data;
222
+ if (preset['key'] !== key)
223
+ throw new Error(`${file}: key должно совпадать с именем файла «${key}»`);
224
+ const title = oneLine(preset['title'], 'title', file);
225
+ if (title === undefined)
226
+ throw new Error(`${file}: нет title`);
227
+ const contract = preset['contract'];
228
+ if (!known.some((e) => e.kind === 'contract' && e.key === contract)) {
229
+ throw new Error(`${file}: контракта «${String(contract)}» нет в индексе`);
230
+ }
231
+ const modules = preset['modules'];
232
+ if (!Array.isArray(modules) || modules.length === 0)
233
+ throw new Error(`${file}: modules — непустой список`);
234
+ for (const item of modules) {
235
+ const moduleKey = typeof item === 'object' && item !== null ? item['key'] : undefined;
236
+ const module = known.find((e) => e.kind === 'module' && e.key === moduleKey);
237
+ if (module === undefined)
238
+ throw new Error(`${file}: модуля «${String(moduleKey)}» нет в индексе`);
239
+ if (!module.applicableTo.includes(String(contract))) {
240
+ throw new Error(`${file}: модуль «${module.key}» не применим к «${String(contract)}»`);
241
+ }
242
+ }
243
+ entries.push({ kind: 'preset', key, path: file, sha256: sha256(content), applicableTo: [String(contract)], patterns: [], title });
244
+ }
245
+ return entries;
246
+ }
194
247
  /** Субагент — `agents/<name>.md`: frontmatter name, description, skills, tools + инструкция. */
195
248
  async function collectAgents(root) {
196
249
  const entries = [];
@@ -222,6 +275,8 @@ export function renderIndex(entries, options) {
222
275
  lines.push(` - key: ${e.key}`, ...(e.title === undefined ? [] : [` title: ${scalar(e.title)}`]), ` path: ${e.path}`,
223
276
  // У skill raw нет: это каталог, raw — у каждого файла.
224
277
  ...(e.files === undefined ? [` raw: ${options.rawBase}/${e.path}`] : []), ` sha256: ${e.sha256}`);
278
+ if (e.deprecated !== undefined)
279
+ lines.push(` deprecated: ${scalar(e.deprecated)}`);
225
280
  if (e.files !== undefined) {
226
281
  lines.push(' files:');
227
282
  for (const f of e.files) {
@@ -230,7 +285,7 @@ export function renderIndex(entries, options) {
230
285
  }
231
286
  if (e.patterns.length > 0)
232
287
  lines.push(` patterns: ${flowList(e.patterns)}`);
233
- if (e.kind === 'module' && e.applicableTo.length > 0) {
288
+ if ((e.kind === 'module' || e.kind === 'preset') && e.applicableTo.length > 0) {
234
289
  lines.push(` applicable_to: ${flowList(e.applicableTo)}`);
235
290
  }
236
291
  if (e.feature !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shahimat/harness",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Harness для проектов на контрактах ai-prompts: генераторы и проверки",
5
5
  "license": "MIT",
6
6
  "author": "Gevorg Nakhapetyan",