@shahimat/harness 0.0.0-stage → 0.1.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 +48 -2
- package/dist/adapters/memory.d.ts +25 -0
- package/dist/adapters/memory.js +111 -0
- package/dist/adapters/render.d.ts +9 -0
- package/dist/adapters/render.js +43 -0
- package/dist/adapters/source.d.ts +30 -0
- package/dist/adapters/source.js +114 -0
- package/dist/adapters/sync.d.ts +21 -0
- package/dist/adapters/sync.js +182 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/checks/changelog.d.ts +10 -0
- package/dist/checks/changelog.js +88 -0
- package/dist/checks/context-index.d.ts +11 -0
- package/dist/checks/context-index.js +339 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/commands/adapters.d.ts +2 -0
- package/dist/commands/adapters.js +18 -0
- package/dist/commands/changelog.d.ts +2 -0
- package/dist/commands/changelog.js +12 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +31 -0
- package/dist/commands/context.d.ts +2 -0
- package/dist/commands/context.js +16 -0
- package/dist/commands/prompt-index.d.ts +2 -0
- package/dist/commands/prompt-index.js +48 -0
- package/dist/commands/report.d.ts +3 -0
- package/dist/commands/report.js +12 -0
- package/dist/commands/types.d.ts +8 -0
- package/dist/commands/types.js +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +101 -0
- package/dist/diagnostics.d.ts +12 -0
- package/dist/diagnostics.js +6 -0
- package/dist/frontmatter.d.ts +8 -0
- package/dist/frontmatter.js +24 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/prompt-index.d.ts +47 -0
- package/dist/prompt-index.js +260 -0
- package/package.json +57 -4
package/README.md
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shahimat/harness
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CLI для проектов на контракте
|
|
4
|
+
[`agent-operational-contract-v3`](https://github.com/Shahimat/ai-prompts/blob/main/prompts/productivity/agent-operational-contract-v3.md):
|
|
5
|
+
механические проверки и генерация, чтобы агент не держал их в памяти.
|
|
6
|
+
|
|
7
|
+
Node ≥ 24. macOS, Linux, Windows.
|
|
8
|
+
|
|
9
|
+
## Запуск
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y @shahimat/harness@<version> <команда> --root .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Версия проекта — `version` в `context/harness.yml`.
|
|
16
|
+
|
|
17
|
+
## Команды
|
|
18
|
+
|
|
19
|
+
- `harness check` — все проверки, включённые в `context/harness.yml`.
|
|
20
|
+
- `harness changelog lint` — `CHANGELOG.md`: строка
|
|
21
|
+
`- YYYY-MM-DD · [tag] суть · почему` до 120 символов, тег из списка,
|
|
22
|
+
порядок дат.
|
|
23
|
+
- `harness context index` — генерация `context/index.yml` из слоёв
|
|
24
|
+
документации (entities, behavior, manuals, conventions, слои модулей).
|
|
25
|
+
- `harness adapters sync` — файлы под Claude Code и Codex из
|
|
26
|
+
`AGENTS.md` и `.agents/`: `CLAUDE.md`, копии skills, субагенты,
|
|
27
|
+
выключенная memory агента.
|
|
28
|
+
- `harness index build | check --raw-base <url>` — индекс
|
|
29
|
+
`prompts/index.yml` репозитория промптов.
|
|
30
|
+
|
|
31
|
+
Коды выхода: `0` — ok, `1` — нарушения, `2` — ошибка вызова или
|
|
32
|
+
конфигурации.
|
|
33
|
+
|
|
34
|
+
## `context/harness.yml`
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
version: 0.1.0
|
|
38
|
+
adapters: [claude-code, codex]
|
|
39
|
+
checks:
|
|
40
|
+
changelog:
|
|
41
|
+
max_line: 120
|
|
42
|
+
tags: [arch, stack, dep, milestone]
|
|
43
|
+
context-index: {}
|
|
44
|
+
adapters: {}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Лицензия
|
|
48
|
+
|
|
49
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Diagnostic } from '../diagnostics.ts';
|
|
2
|
+
import type { Action } from './sync.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Memory агента запрещена контрактом: адаптер выключает её настройкой
|
|
5
|
+
* инструмента в проекте.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CLAUDE_SETTINGS = ".claude/settings.json";
|
|
8
|
+
export declare const CODEX_CONFIG = ".codex/config.toml";
|
|
9
|
+
export declare function codexConfig(): string;
|
|
10
|
+
/**
|
|
11
|
+
* `.claude/settings.json` правится по ключу: файл общий с permissions и
|
|
12
|
+
* hooks проекта, остальное содержимое сохраняется.
|
|
13
|
+
*/
|
|
14
|
+
export declare function claudeMemory(root: string): Promise<{
|
|
15
|
+
actions: Action[];
|
|
16
|
+
diagnostics: Diagnostic[];
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* `.codex/config.toml`: нет файла или он наш — генерируем целиком; написан
|
|
20
|
+
* вручную — не трогаем, только проверяем ключи.
|
|
21
|
+
*/
|
|
22
|
+
export declare function codexMemory(root: string): Promise<{
|
|
23
|
+
actions: Action[];
|
|
24
|
+
diagnostics: Diagnostic[];
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { GENERATED_MARK } from './render.js';
|
|
4
|
+
/**
|
|
5
|
+
* Memory агента запрещена контрактом: адаптер выключает её настройкой
|
|
6
|
+
* инструмента в проекте.
|
|
7
|
+
*/
|
|
8
|
+
export const CLAUDE_SETTINGS = '.claude/settings.json';
|
|
9
|
+
export const CODEX_CONFIG = '.codex/config.toml';
|
|
10
|
+
/** Ключи Codex, которые выключают memories: `таблица.ключ` → значение. */
|
|
11
|
+
const CODEX_MEMORY_KEYS = ['features.memories', 'memories.use_memories', 'memories.generate_memories'];
|
|
12
|
+
export function codexConfig() {
|
|
13
|
+
return [
|
|
14
|
+
`# ${GENERATED_MARK} — do not edit; memory агента запрещена контрактом`,
|
|
15
|
+
'[features]',
|
|
16
|
+
'memories = false',
|
|
17
|
+
'',
|
|
18
|
+
'[memories]',
|
|
19
|
+
'use_memories = false',
|
|
20
|
+
'generate_memories = false',
|
|
21
|
+
'',
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
|
24
|
+
async function readOrNull(abs) {
|
|
25
|
+
try {
|
|
26
|
+
return await readFile(abs, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code === 'ENOENT')
|
|
30
|
+
return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `.claude/settings.json` правится по ключу: файл общий с permissions и
|
|
36
|
+
* hooks проекта, остальное содержимое сохраняется.
|
|
37
|
+
*/
|
|
38
|
+
export async function claudeMemory(root) {
|
|
39
|
+
const current = await readOrNull(path.join(root, CLAUDE_SETTINGS));
|
|
40
|
+
const write = (data) => ({
|
|
41
|
+
op: 'write',
|
|
42
|
+
path: CLAUDE_SETTINGS,
|
|
43
|
+
content: Buffer.from(`${JSON.stringify(data, null, 2)}\n`, 'utf8'),
|
|
44
|
+
});
|
|
45
|
+
if (current === null)
|
|
46
|
+
return { actions: [write({ autoMemoryEnabled: false })], diagnostics: [] };
|
|
47
|
+
let data;
|
|
48
|
+
try {
|
|
49
|
+
data = JSON.parse(current);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
actions: [],
|
|
54
|
+
diagnostics: [{ file: CLAUDE_SETTINGS, message: `невалидный JSON: ${error.message}` }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
58
|
+
return { actions: [], diagnostics: [{ file: CLAUDE_SETTINGS, message: 'корень должен быть объектом' }] };
|
|
59
|
+
}
|
|
60
|
+
const settings = data;
|
|
61
|
+
if (settings['autoMemoryEnabled'] === false)
|
|
62
|
+
return { actions: [], diagnostics: [] };
|
|
63
|
+
return { actions: [write({ ...settings, autoMemoryEnabled: false })], diagnostics: [] };
|
|
64
|
+
}
|
|
65
|
+
/** Значения `таблица.ключ` из простого TOML: таблицы, пары, dotted-ключи. */
|
|
66
|
+
function scanToml(text) {
|
|
67
|
+
const values = new Map();
|
|
68
|
+
let table = '';
|
|
69
|
+
for (const raw of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
70
|
+
const line = raw.replace(/\s+#.*$/, '').trim();
|
|
71
|
+
if (line === '' || line.startsWith('#'))
|
|
72
|
+
continue;
|
|
73
|
+
const header = /^\[([^\[\]]+)\]$/.exec(line);
|
|
74
|
+
if (header !== null) {
|
|
75
|
+
table = (header[1] ?? '').trim();
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const pair = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line);
|
|
79
|
+
if (pair === null)
|
|
80
|
+
continue;
|
|
81
|
+
const key = table === '' ? (pair[1] ?? '') : `${table}.${pair[1] ?? ''}`;
|
|
82
|
+
values.set(key, (pair[2] ?? '').trim());
|
|
83
|
+
}
|
|
84
|
+
return values;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `.codex/config.toml`: нет файла или он наш — генерируем целиком; написан
|
|
88
|
+
* вручную — не трогаем, только проверяем ключи.
|
|
89
|
+
*/
|
|
90
|
+
export async function codexMemory(root) {
|
|
91
|
+
const current = await readOrNull(path.join(root, CODEX_CONFIG));
|
|
92
|
+
const expected = codexConfig();
|
|
93
|
+
if (current === null || current.includes(GENERATED_MARK)) {
|
|
94
|
+
if (current === expected)
|
|
95
|
+
return { actions: [], diagnostics: [] };
|
|
96
|
+
return { actions: [{ op: 'write', path: CODEX_CONFIG, content: Buffer.from(expected, 'utf8') }], diagnostics: [] };
|
|
97
|
+
}
|
|
98
|
+
const values = scanToml(current);
|
|
99
|
+
const missing = CODEX_MEMORY_KEYS.filter((key) => values.get(key) !== 'false');
|
|
100
|
+
if (missing.length === 0)
|
|
101
|
+
return { actions: [], diagnostics: [] };
|
|
102
|
+
return {
|
|
103
|
+
actions: [],
|
|
104
|
+
diagnostics: [
|
|
105
|
+
{
|
|
106
|
+
file: CODEX_CONFIG,
|
|
107
|
+
message: `написан вручную, memory не выключена: добавь ${missing.map((k) => `${k} = false`).join(', ')}`,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AgentDef } from './source.ts';
|
|
2
|
+
/** Метка сгенерированного файла: по ней sync отличает свои файлы от ручных. */
|
|
3
|
+
export declare const GENERATED_MARK = "generated by harness adapters sync";
|
|
4
|
+
/** Файл-метка внутри скопированного каталога skill. */
|
|
5
|
+
export declare const SKILL_MARKER_FILE = ".harness-generated";
|
|
6
|
+
export declare function claudeMd(): string;
|
|
7
|
+
export declare function skillMarker(name: string): string;
|
|
8
|
+
export declare function claudeAgent(agent: AgentDef): string;
|
|
9
|
+
export declare function codexAgent(agent: AgentDef): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { stringify } from 'yaml';
|
|
2
|
+
/** Метка сгенерированного файла: по ней sync отличает свои файлы от ручных. */
|
|
3
|
+
export const GENERATED_MARK = 'generated by harness adapters sync';
|
|
4
|
+
/** Файл-метка внутри скопированного каталога skill. */
|
|
5
|
+
export const SKILL_MARKER_FILE = '.harness-generated';
|
|
6
|
+
export function claudeMd() {
|
|
7
|
+
return `<!-- ${GENERATED_MARK} — do not edit, правь AGENTS.md -->\n@AGENTS.md\n`;
|
|
8
|
+
}
|
|
9
|
+
export function skillMarker(name) {
|
|
10
|
+
return `${GENERATED_MARK} from .agents/skills/${name} — do not edit\n`;
|
|
11
|
+
}
|
|
12
|
+
export function claudeAgent(agent) {
|
|
13
|
+
const fields = { name: agent.name, description: agent.description };
|
|
14
|
+
// Claude Code ждёт tools строкой через запятую.
|
|
15
|
+
if (agent.tools.length > 0)
|
|
16
|
+
fields['tools'] = agent.tools.join(', ');
|
|
17
|
+
if (agent.skills.length > 0)
|
|
18
|
+
fields['skills'] = agent.skills;
|
|
19
|
+
const yaml = stringify(fields, { lineWidth: 0 });
|
|
20
|
+
return `---\n# ${GENERATED_MARK} from ${agent.source} — do not edit\n${yaml}---\n\n${agent.instructions}\n`;
|
|
21
|
+
}
|
|
22
|
+
/** Строка TOML в одну строку: экранирование JSON совместимо с basic string TOML. */
|
|
23
|
+
const tomlString = (value) => JSON.stringify(value);
|
|
24
|
+
/** Многострочная basic string TOML; `"""` внутри текста экранируется. */
|
|
25
|
+
function tomlMultiline(value) {
|
|
26
|
+
const escaped = value
|
|
27
|
+
.replace(/\\/g, '\\\\')
|
|
28
|
+
.replace(/"""/g, '""\\"')
|
|
29
|
+
// Управляющие символы, кроме перевода строки и табуляции, в TOML недопустимы.
|
|
30
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
31
|
+
return `"""\n${escaped}\n"""`;
|
|
32
|
+
}
|
|
33
|
+
export function codexAgent(agent) {
|
|
34
|
+
// У Codex нет предзагрузки skills: явно просим загрузить их.
|
|
35
|
+
const preface = agent.skills.length > 0 ? `Перед работой загрузи skills: ${agent.skills.join(', ')}.\n\n` : '';
|
|
36
|
+
return [
|
|
37
|
+
`# ${GENERATED_MARK} from ${agent.source} — do not edit`,
|
|
38
|
+
`name = ${tomlString(agent.name)}`,
|
|
39
|
+
`description = ${tomlString(agent.description)}`,
|
|
40
|
+
`developer_instructions = ${tomlMultiline(preface + agent.instructions)}`,
|
|
41
|
+
'',
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Diagnostic } from '../diagnostics.ts';
|
|
2
|
+
export declare const AGENTS_MD = "AGENTS.md";
|
|
3
|
+
export declare const SKILLS_DIR = ".agents/skills";
|
|
4
|
+
export declare const AGENTS_DIR = ".agents/agents";
|
|
5
|
+
/** Файл skill: путь внутри каталога skill (через `/`) и содержимое. */
|
|
6
|
+
export interface SkillFile {
|
|
7
|
+
rel: string;
|
|
8
|
+
content: Buffer;
|
|
9
|
+
}
|
|
10
|
+
export interface Skill {
|
|
11
|
+
name: string;
|
|
12
|
+
files: SkillFile[];
|
|
13
|
+
}
|
|
14
|
+
/** Субагент из `.agents/agents/<name>.md`. */
|
|
15
|
+
export interface AgentDef {
|
|
16
|
+
name: string;
|
|
17
|
+
source: string;
|
|
18
|
+
description: string;
|
|
19
|
+
skills: string[];
|
|
20
|
+
tools: string[];
|
|
21
|
+
instructions: string;
|
|
22
|
+
}
|
|
23
|
+
export interface Sources {
|
|
24
|
+
hasAgentsMd: boolean;
|
|
25
|
+
skills: Skill[];
|
|
26
|
+
agents: AgentDef[];
|
|
27
|
+
diagnostics: Diagnostic[];
|
|
28
|
+
}
|
|
29
|
+
/** Читает канонические источники адаптеров. */
|
|
30
|
+
export declare function readSources(root: string): Promise<Sources>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { access, readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeEol, parseFrontmatter } from '../frontmatter.js';
|
|
4
|
+
export const AGENTS_MD = 'AGENTS.md';
|
|
5
|
+
export const SKILLS_DIR = '.agents/skills';
|
|
6
|
+
export const AGENTS_DIR = '.agents/agents';
|
|
7
|
+
const AGENT_KEYS = ['name', 'description', 'skills', 'tools'];
|
|
8
|
+
async function listDir(dir) {
|
|
9
|
+
try {
|
|
10
|
+
return (await readdir(dir)).sort();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === 'ENOENT')
|
|
14
|
+
return [];
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function readSkill(root, name) {
|
|
19
|
+
const dir = path.join(root, SKILLS_DIR, name);
|
|
20
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
21
|
+
const files = [];
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry.isFile())
|
|
24
|
+
continue;
|
|
25
|
+
const abs = path.join(entry.parentPath, entry.name);
|
|
26
|
+
files.push({ rel: path.relative(dir, abs).split(path.sep).join('/'), content: await readFile(abs) });
|
|
27
|
+
}
|
|
28
|
+
files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
29
|
+
return { name, files };
|
|
30
|
+
}
|
|
31
|
+
function stringList(value) {
|
|
32
|
+
if (value === undefined || value === null)
|
|
33
|
+
return [];
|
|
34
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string' && v !== ''))
|
|
35
|
+
return null;
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function parseAgent(file, text, report) {
|
|
39
|
+
let fm;
|
|
40
|
+
try {
|
|
41
|
+
fm = parseFrontmatter(text);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
report(`невалидный frontmatter: ${error.message.split('\n')[0]}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (fm === null) {
|
|
48
|
+
report('нет frontmatter (--- name, description ---)');
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const extra = Object.keys(fm).filter((k) => !AGENT_KEYS.includes(k));
|
|
52
|
+
if (extra.length > 0)
|
|
53
|
+
report(`неизвестные поля frontmatter: ${extra.join(', ')}`);
|
|
54
|
+
const expected = path.posix.basename(file, '.md');
|
|
55
|
+
const { name, description } = fm;
|
|
56
|
+
if (name !== expected)
|
|
57
|
+
report(`name «${String(name)}» не совпадает с именем файла «${expected}»`);
|
|
58
|
+
if (typeof description !== 'string' || description.trim() === '')
|
|
59
|
+
report('нет поля description');
|
|
60
|
+
const skills = stringList(fm['skills']);
|
|
61
|
+
if (skills === null)
|
|
62
|
+
report('skills должно быть списком строк');
|
|
63
|
+
const tools = stringList(fm['tools']);
|
|
64
|
+
if (tools === null)
|
|
65
|
+
report('tools должно быть списком строк');
|
|
66
|
+
const body = normalizeEol(text).split('\n');
|
|
67
|
+
const end = body.indexOf('---', 1);
|
|
68
|
+
const instructions = body.slice(end + 1).join('\n').trim();
|
|
69
|
+
if (instructions === '')
|
|
70
|
+
report('пустое тело: инструкции субагента');
|
|
71
|
+
if (name !== expected || typeof description !== 'string' || skills === null || tools === null)
|
|
72
|
+
return null;
|
|
73
|
+
if (description.trim() === '' || instructions === '')
|
|
74
|
+
return null;
|
|
75
|
+
return { name: expected, source: file, description: description.trim(), skills, tools, instructions };
|
|
76
|
+
}
|
|
77
|
+
/** Читает канонические источники адаптеров. */
|
|
78
|
+
export async function readSources(root) {
|
|
79
|
+
const diagnostics = [];
|
|
80
|
+
const hasAgentsMd = await access(path.join(root, AGENTS_MD)).then(() => true, () => false);
|
|
81
|
+
const skills = [];
|
|
82
|
+
for (const name of await listDir(path.join(root, SKILLS_DIR))) {
|
|
83
|
+
const skill = await readSkill(root, name).catch((error) => {
|
|
84
|
+
if (error.code === 'ENOTDIR')
|
|
85
|
+
return null;
|
|
86
|
+
throw error;
|
|
87
|
+
});
|
|
88
|
+
if (skill === null)
|
|
89
|
+
continue;
|
|
90
|
+
if (!skill.files.some((f) => f.rel === 'SKILL.md')) {
|
|
91
|
+
diagnostics.push({ file: `${SKILLS_DIR}/${name}`, message: 'нет SKILL.md — не skill, пропущен' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
skills.push(skill);
|
|
95
|
+
}
|
|
96
|
+
const agents = [];
|
|
97
|
+
for (const entry of await listDir(path.join(root, AGENTS_DIR))) {
|
|
98
|
+
if (!entry.endsWith('.md'))
|
|
99
|
+
continue;
|
|
100
|
+
const file = `${AGENTS_DIR}/${entry}`;
|
|
101
|
+
const report = (message) => {
|
|
102
|
+
diagnostics.push({ file, message });
|
|
103
|
+
};
|
|
104
|
+
const agent = parseAgent(file, await readFile(path.join(root, file), 'utf8'), report);
|
|
105
|
+
if (agent === null)
|
|
106
|
+
continue;
|
|
107
|
+
for (const skill of agent.skills) {
|
|
108
|
+
if (!skills.some((s) => s.name === skill))
|
|
109
|
+
report(`skill «${skill}» не найден в ${SKILLS_DIR}`);
|
|
110
|
+
}
|
|
111
|
+
agents.push(agent);
|
|
112
|
+
}
|
|
113
|
+
return { hasAgentsMd, skills, agents, diagnostics };
|
|
114
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Diagnostic } from '../diagnostics.ts';
|
|
2
|
+
export declare const KNOWN_ADAPTERS: readonly ['claude-code', 'codex'];
|
|
3
|
+
export type AdapterName = (typeof KNOWN_ADAPTERS)[number];
|
|
4
|
+
export interface Action {
|
|
5
|
+
op: 'write' | 'delete';
|
|
6
|
+
/** Путь от корня проекта, через `/`. */
|
|
7
|
+
path: string;
|
|
8
|
+
content?: Buffer;
|
|
9
|
+
}
|
|
10
|
+
export interface SyncResult {
|
|
11
|
+
actions: Action[];
|
|
12
|
+
diagnostics: Diagnostic[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Сравнивает план с диском. Файлы без метки harness не трогаются никогда:
|
|
16
|
+
* они попадают в нарушения, а не в действия.
|
|
17
|
+
*/
|
|
18
|
+
export declare function computeSync(root: string, adapters: readonly AdapterName[]): Promise<SyncResult>;
|
|
19
|
+
export declare function applyActions(root: string, actions: Action[]): Promise<void>;
|
|
20
|
+
/** Проверка для `harness check`: всё сгенерированное совпадает с источниками. */
|
|
21
|
+
export declare function checkAdapters(root: string, adapters: readonly AdapterName[]): Promise<Diagnostic[]>;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { claudeMemory, CODEX_CONFIG, codexMemory } from './memory.js';
|
|
4
|
+
import { claudeAgent, claudeMd, codexAgent, GENERATED_MARK, SKILL_MARKER_FILE, skillMarker } from './render.js';
|
|
5
|
+
import { AGENTS_MD, readSources } from './source.js';
|
|
6
|
+
export const KNOWN_ADAPTERS = ['claude-code', 'codex'];
|
|
7
|
+
const CLAUDE_MD = 'CLAUDE.md';
|
|
8
|
+
const CLAUDE_SKILLS = '.claude/skills';
|
|
9
|
+
const CLAUDE_AGENTS = '.claude/agents';
|
|
10
|
+
const CODEX_AGENTS = '.codex/agents';
|
|
11
|
+
const text = (value) => Buffer.from(value, 'utf8');
|
|
12
|
+
const isOurs = (content) => content.includes(GENERATED_MARK);
|
|
13
|
+
async function readOrNull(abs) {
|
|
14
|
+
try {
|
|
15
|
+
return await readFile(abs);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const code = error.code;
|
|
19
|
+
if (code === 'ENOENT' || code === 'EISDIR')
|
|
20
|
+
return null;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function listFiles(abs) {
|
|
25
|
+
try {
|
|
26
|
+
const entries = await readdir(abs, { recursive: true, withFileTypes: true });
|
|
27
|
+
return entries
|
|
28
|
+
.filter((e) => e.isFile())
|
|
29
|
+
.map((e) => path.relative(abs, path.join(e.parentPath, e.name)).split(path.sep).join('/'))
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === 'ENOENT')
|
|
34
|
+
return [];
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function listNames(abs) {
|
|
39
|
+
try {
|
|
40
|
+
return (await readdir(abs)).sort();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT')
|
|
44
|
+
return [];
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function plan(root, adapters) {
|
|
49
|
+
const sources = await readSources(root);
|
|
50
|
+
const diagnostics = [...sources.diagnostics];
|
|
51
|
+
const units = [];
|
|
52
|
+
if (adapters.includes('claude-code')) {
|
|
53
|
+
if (sources.hasAgentsMd) {
|
|
54
|
+
units.push({ kind: 'file', path: CLAUDE_MD, content: text(claudeMd()) });
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
diagnostics.push({ file: AGENTS_MD, message: 'файл не найден: CLAUDE.md не из чего генерировать' });
|
|
58
|
+
}
|
|
59
|
+
for (const skill of sources.skills) {
|
|
60
|
+
const files = new Map(skill.files.map((f) => [f.rel, f.content]));
|
|
61
|
+
files.set(SKILL_MARKER_FILE, text(skillMarker(skill.name)));
|
|
62
|
+
units.push({ kind: 'dir', path: `${CLAUDE_SKILLS}/${skill.name}`, files });
|
|
63
|
+
}
|
|
64
|
+
for (const agent of sources.agents) {
|
|
65
|
+
units.push({ kind: 'file', path: `${CLAUDE_AGENTS}/${agent.name}.md`, content: text(claudeAgent(agent)) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (adapters.includes('codex')) {
|
|
69
|
+
for (const agent of sources.agents) {
|
|
70
|
+
units.push({ kind: 'file', path: `${CODEX_AGENTS}/${agent.name}.toml`, content: text(codexAgent(agent)) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { units, diagnostics };
|
|
74
|
+
}
|
|
75
|
+
const conflict = (file) => ({
|
|
76
|
+
file,
|
|
77
|
+
message: 'существует и написан не harness — не трогаю; перенеси содержимое в источник (.agents/, AGENTS.md) и удали файл',
|
|
78
|
+
});
|
|
79
|
+
/** Сгенерированные файлы и каталоги на диске, которые не входят в план. */
|
|
80
|
+
async function staleActions(root, planned) {
|
|
81
|
+
const actions = [];
|
|
82
|
+
const consider = async (rel) => {
|
|
83
|
+
if (planned.has(rel))
|
|
84
|
+
return;
|
|
85
|
+
const content = await readOrNull(path.join(root, rel));
|
|
86
|
+
if (content !== null && isOurs(content))
|
|
87
|
+
actions.push({ op: 'delete', path: rel });
|
|
88
|
+
};
|
|
89
|
+
await consider(CLAUDE_MD);
|
|
90
|
+
await consider(CODEX_CONFIG);
|
|
91
|
+
for (const name of await listNames(path.join(root, CLAUDE_AGENTS))) {
|
|
92
|
+
if (name.endsWith('.md'))
|
|
93
|
+
await consider(`${CLAUDE_AGENTS}/${name}`);
|
|
94
|
+
}
|
|
95
|
+
for (const name of await listNames(path.join(root, CODEX_AGENTS))) {
|
|
96
|
+
if (name.endsWith('.toml'))
|
|
97
|
+
await consider(`${CODEX_AGENTS}/${name}`);
|
|
98
|
+
}
|
|
99
|
+
for (const name of await listNames(path.join(root, CLAUDE_SKILLS))) {
|
|
100
|
+
const dir = `${CLAUDE_SKILLS}/${name}`;
|
|
101
|
+
if (planned.has(dir))
|
|
102
|
+
continue;
|
|
103
|
+
const marker = await readOrNull(path.join(root, dir, SKILL_MARKER_FILE));
|
|
104
|
+
if (marker !== null && isOurs(marker))
|
|
105
|
+
actions.push({ op: 'delete', path: dir });
|
|
106
|
+
}
|
|
107
|
+
return actions;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Сравнивает план с диском. Файлы без метки harness не трогаются никогда:
|
|
111
|
+
* они попадают в нарушения, а не в действия.
|
|
112
|
+
*/
|
|
113
|
+
export async function computeSync(root, adapters) {
|
|
114
|
+
const { units, diagnostics } = await plan(root, adapters);
|
|
115
|
+
const actions = [];
|
|
116
|
+
for (const unit of units) {
|
|
117
|
+
if (unit.kind === 'file') {
|
|
118
|
+
const current = await readOrNull(path.join(root, unit.path));
|
|
119
|
+
if (current === null)
|
|
120
|
+
actions.push({ op: 'write', path: unit.path, content: unit.content });
|
|
121
|
+
else if (!isOurs(current))
|
|
122
|
+
diagnostics.push(conflict(unit.path));
|
|
123
|
+
else if (!current.equals(unit.content))
|
|
124
|
+
actions.push({ op: 'write', path: unit.path, content: unit.content });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const existing = await listFiles(path.join(root, unit.path));
|
|
128
|
+
if (existing.length > 0) {
|
|
129
|
+
const marker = await readOrNull(path.join(root, unit.path, SKILL_MARKER_FILE));
|
|
130
|
+
if (marker === null || !isOurs(marker)) {
|
|
131
|
+
diagnostics.push(conflict(unit.path));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const [rel, content] of unit.files) {
|
|
136
|
+
const current = await readOrNull(path.join(root, unit.path, rel));
|
|
137
|
+
if (current === null || !current.equals(content)) {
|
|
138
|
+
actions.push({ op: 'write', path: `${unit.path}/${rel}`, content });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const rel of existing) {
|
|
142
|
+
if (!unit.files.has(rel))
|
|
143
|
+
actions.push({ op: 'delete', path: `${unit.path}/${rel}` });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Memory агента запрещена контрактом — выключаем её настройкой инструмента.
|
|
147
|
+
const planned = new Set(units.map((u) => u.path));
|
|
148
|
+
for (const [adapter, apply] of [
|
|
149
|
+
['claude-code', claudeMemory],
|
|
150
|
+
['codex', codexMemory],
|
|
151
|
+
]) {
|
|
152
|
+
if (!adapters.includes(adapter))
|
|
153
|
+
continue;
|
|
154
|
+
const memory = await apply(root);
|
|
155
|
+
actions.push(...memory.actions);
|
|
156
|
+
diagnostics.push(...memory.diagnostics);
|
|
157
|
+
if (adapter === 'codex')
|
|
158
|
+
planned.add(CODEX_CONFIG);
|
|
159
|
+
}
|
|
160
|
+
actions.push(...(await staleActions(root, planned)));
|
|
161
|
+
return { actions, diagnostics };
|
|
162
|
+
}
|
|
163
|
+
export async function applyActions(root, actions) {
|
|
164
|
+
for (const action of actions) {
|
|
165
|
+
const abs = path.join(root, action.path);
|
|
166
|
+
if (action.op === 'delete') {
|
|
167
|
+
await rm(abs, { recursive: true, force: true });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
171
|
+
await writeFile(abs, action.content ?? Buffer.alloc(0));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Проверка для `harness check`: всё сгенерированное совпадает с источниками. */
|
|
175
|
+
export async function checkAdapters(root, adapters) {
|
|
176
|
+
const { actions, diagnostics } = await computeSync(root, adapters);
|
|
177
|
+
for (const action of actions) {
|
|
178
|
+
const what = action.op === 'write' ? 'отсутствует или устарел' : 'лишний';
|
|
179
|
+
diagnostics.push({ file: action.path, message: `${what}: запусти harness adapters sync` });
|
|
180
|
+
}
|
|
181
|
+
return diagnostics;
|
|
182
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChangelogOptions } from '../config.ts';
|
|
2
|
+
import type { Diagnostic } from '../diagnostics.ts';
|
|
3
|
+
export declare const CHANGELOG_PATH = "CHANGELOG.md";
|
|
4
|
+
/**
|
|
5
|
+
* Проверяет журнал решений. Формат записи:
|
|
6
|
+
* `- YYYY-MM-DD · [tag] суть · почему`. До первой записи — свободный
|
|
7
|
+
* текст (заголовок, пояснение), после — только записи и пустые строки.
|
|
8
|
+
*/
|
|
9
|
+
export declare function lintChangelog(text: string, options: ChangelogOptions, now: Date, file?: string): Diagnostic[];
|
|
10
|
+
export declare function checkChangelog(root: string, options: ChangelogOptions, now: Date): Promise<Diagnostic[]>;
|