@aimana/core
v0.1.0
Published
AIMana core: schemas, .aimana/ file layer, templates
Readme
@aimana/core
Единственный способ читать и писать каталог .aimana/ проекта. Схемы, парсер
markdown с YAML-frontmatter, файловый слой, дерево проекта, вычисление состояния.
Что внутри
| Модуль | Назначение |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schema/ | zod-схемы frontmatter: Project, Feature, Task, Stage, Decision, Pipeline, enum-ы статусов |
| document/ | parseDocument, serializeDocument (ключи в порядке схемы), splitFrontmatter, секции h2: getSection, setSection |
| fs/ | AimanaFs (чтение с валидацией, атомарная запись), AimanaPaths, readTree, ошибки |
| state/ | buildGraph (DAG, циклы, битые ссылки), computeState (эффективные статусы, blockedBy, прогресс, nextStage, доступные к старту), таблицы переходов canTransition / assertTransition |
| ops/ | Записи в .aimana/ от лица демона: createFeature/createTask, reportProgress/reportArtifact/logDecision, план таски (applyPlan, approvePlan, setTaskStatus) |
| templates/ | движок подстановки, разрешение шаблонов, пайплайны, шаблоны архитектур, пресеты стеков, buildPromptContext, renderStagePrompt / renderActionPrompt |
| catalogue/ | справочник опций проекта для CLI и веба: языки, менеджеры пакетов, платформы, имена гейтов, политики с подписями и описаниями |
Пример
import { AimanaFs, computeState, readTree } from '@aimana/core';
const afs = new AimanaFs('/path/to/repo');
if (await afs.exists()) {
const tree = await readTree(afs);
for (const { feature, tasks } of tree.features) {
console.log(feature.frontmatter.id, tasks.length);
}
for (const error of tree.errors) console.warn(error.file, error.message);
await afs.updateStageSection('auth', '001-login-form', '02', 'Результат', 'Сделано.');
const state = computeState(tree);
for (const task of state.available) console.log('можно стартовать:', task.ref);
for (const issue of state.issues) console.warn(issue.message);
}Правила вывода статусов
computeState чистая функция над ProjectTree:
- Таска
done, если сохраненоdoneили все её стейджиdone.blocked, если открыты её зависимости изdepends_onили зависимости её фичи.in-progress, если хоть один стейдж сдвинулся сtodo. Иначе сохранённый статус. - Фича
done, когда все таскиdone;blockedпри открытых зависимостях;in-progress, если есть таска в работе или заблокированная;archivedкак есть;status_override: trueзамораживает сохранённый статус. - Доступна к старту: эффективный статус
todoилиplanned, нет блокеров, фича неarchived, таска не в цикле.
Переходы статусов, которые демон обязан проверять перед записью:
STAGE_TRANSITIONS, TASK_TRANSITIONS, FEATURE_TRANSITIONS.
Гарантии
- Невалидный файл никогда не попадает на диск: запись валидирует перед
rename. - Неизвестные ключи frontmatter сохраняются (совместимость с будущими версиями формата).
- Сериализация детерминирована: порядок ключей по схеме,
indent: 2, один\nв конце. readTreeне падает на одном битом файле, а возвращает его вerrors[].- Проблемы графа (цикл, битая ссылка) попадают в
state.issues, а не бросаются.
Настройки проекта (ops/project.ts)
updateProjectBody заменяет тело PROJECT.md — правила проекта, которые CLAUDE.md
импортирует целиком. Поля updated у проекта в схеме нет, поэтому ничего не проставляется.
updateProjectSettings меняет frontmatter частично: поля, которых нет в патче, не
трогаются. Схема — looseObject, у проекта бывают ключи, о которых операция не слышала, и
форма с пятью полями не должна стирать шестое. Внутри gates и policies.security мерж
идёт по ключам, где null удаляет ключ; пустая команда гейта — тоже удаление, потому что
команды без содержимого в формате не бывает. platforms заменяются целиком: иначе платформу
не убрать. Результат валидируется схемой до записи — невалидное значение бросает
AimanaValidationError и на диск не попадает. Пустой патч — тоже ошибка, а не молчаливая
перезапись файла.
Справочник опций (catalogue/project-options.ts) — списки языков, менеджеров пакетов,
платформ, имён гейтов и политик с русскими подписями. Всё это подсказки, а не
ограничения: схема принимает любой язык, любую платформу и любое имя гейта. Живёт в core,
потому что нужен и @aimana/cli (опрос aimana init), и @aimana/web (форма настроек), а
третья копия была бы третьей правдой.
План таски (ops/plan.ts)
Модель отдаёт план как данные — TaskPlanSchema:
{ stages: [{ kind, title, goal, size?, artifacts, gates, criteria }], risks, out_of_scope }parsePlanJson(text) достаёт то же самое из ответа, когда инструмент aimana_submit_plan
не был вызван: последний ```json-блок, затем любой блок, затем фрагмент от первой
{ до последней }.
Дальше данные превращаются в markdown и markdown становится источником правды:
applyPlanпишетstagesво frontmatterTASK.mdи разворачивает план в секции## План(блок### Стейдж NN: …на каждый стейдж),## Риски,## Не входит. Статус таски →plan-review.- Человек правит эти секции руками до подтверждения — хоть в вебе, хоть в редакторе.
approvePlanперечитывает## Планпарсером (parsePlanSection), а не то, что записала модель: дописанный руками стейдж попадает во frontmatter и получает свойSTAGE-xx.md. Статус иrun_idстейджей, переживших перепланирование (тот жеid, то же название), сохраняются.
renderPlanSection/parsePlanSection обратимы, это проверяется round-trip тестом: иначе
правка руками теряла бы данные. Созданные файлы стейджей получают заготовку ТЗ (цель,
ожидаемые артефакты, критерии приёмки чекбоксами) — само ТЗ генерирует T-032.
Фикстуры
test/fixtures/sample и test/fixtures/broken генерируются скриптом
test/make-fixtures.mjs через реальный сериализатор. Перегенерировать после
изменения формата:
pnpm --filter @aimana/core build && node packages/core/test/make-fixtures.mjsШаблоны промптов
Промпт для запуска Claude собирается из шаблона и контекста, собранного по .aimana/.
Где лежат шаблоны
Каждый файл ищется по цепочке, побеждает первый найденный (мержа содержимого нет):
<repo>/.aimana/templates/— переопределение для конкретного проекта, лежит в git;~/.aimana/templates/— личные шаблоны пользователя;packages/core/templates/— встроенные, едут в npm-пакет.
templates/
pipelines/{default,hotfix,research}.yaml каркасы стейджей таски
stages/<kind>.md промпт стейджа: spec, plan, implement, test,
review, security, docs, custom
actions/<action>.md plan-task, spec-full, spec-stage, summarize-result
partials/{context,stage,system-tail}.md общие куски, подключаются через {{> name}}Чтобы переопределить один промпт, достаточно положить свой файл рядом по тому же пути:
.aimana/templates/stages/implement.md. Партиалы переопределяются так же по отдельности,
поэтому можно поменять только «системный хвост», не трогая остальное.
Движок
Своё подмножество mustache (~230 строк, без зависимостей). eta из плана не подошёл: нужен
синтаксис {{...}} и строгий режим — незаполненный плейсхолдер должен ронять рендер, а не
молча давать дыру в промпте. Ни eta, ни mustache так не умеют.
| Конструкция | Что делает |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| {{path.to.value}} | подстановка; отсутствующее имя — ошибка, объект — ошибка, массив строк склеивается через \n |
| {{#name}}…{{/name}} | секция: массив — повтор на каждый элемент, объект или скаляр — кладётся на стек контекстов, пусто или ложь — пропуск |
| {{^name}}…{{/name}} | обратная секция: рендерится, когда значения нет или оно пустое |
| {{.}} | текущий элемент стека (элемент массива, значение скалярной секции) |
| {{> partial}} | вставка partials/<partial>.md; отступ строки переносится на все её строки |
| {{! комментарий }} | выкидывается |
Строка, в которой кроме тега секции, закрытия, комментария или partial-а ничего нет, исчезает целиком: служебные теги не оставляют пустых строк.
Основные плейсхолдеры
buildPromptContext собирает контекст из готового ProjectTree (чистая функция, диск не читает).
| Плейсхолдер | Что внутри |
| ---------------------- | -------------------------------------------------------------------------------- |
| {{project.rules}} | тело PROJECT.md без ведущего # ... |
| {{policies}} | политики безопасности и тестирования готовым markdown-списком |
| {{stage.spec}} | секция ## ТЗ текущего стейджа, пусто если ТЗ ещё нет |
| {{artifacts}} | ожидаемые артефакты стейджа списком (stage.artifacts — тот же список массивом) |
| {{previous.results}} | секции ## Результат предыдущих стейджей, блоками с заголовками |
| {{decisionsText}} | ADR, чьи теги пересекаются с тегами фичи |
| {{gateLog}} | лог упавшего гейта, если запуск повторный |
Рядом с готовыми блоками лежат структурированные данные (project.gates, task.stages,
stage.artifacts, decisions), чтобы шаблон мог собрать список по-своему.
Пример
import {
AimanaFs,
buildPromptContext,
createTemplateResolver,
loadPipeline,
readTree,
renderStagePrompt,
} from '@aimana/core';
const afs = new AimanaFs('/path/to/repo');
const tree = await readTree(afs);
const resolver = createTemplateResolver({ root: '/path/to/repo' });
const pipeline = await loadPipeline(resolver, 'default');
const context = buildPromptContext({
tree,
featureId: 'auth',
taskId: '001-login-form',
stageId: '02',
pipeline,
});
const prompt = await renderStagePrompt(resolver, 'implement', context);renderActionPrompt(resolver, 'plan-task', context) рендерит шаблон действия. Обе функции
строгие: опечатка в имени плейсхолдера даёт TemplateRenderError с именем шаблона и строкой.
Пайплайны валидируются PipelineSchema: битый YAML или несовпадение id с именем файла дают
AimanaValidationError, неизвестный id — TemplateNotFoundError.
Файлы
packages/core/templates/**/*.mdисключены из prettier: он считает{{#section}}абзацем и вставляет пустые строки внутрь списков, ломая рендер.
Свои шаблоны
Архитектуры, пресеты стеков и политики ищутся по одной цепочке, побеждает первый уровень, объявивший id:
<repo>/.aimana/templates/— шаблоны этого репозитория, лежат в git;~/.aimana/templates/— личные шаблоны пользователя, общие для всех его проектов;- встроенные в пакет.
~/.aimana/templates/ или <repo>/.aimana/templates/
architectures/<id>/template.yaml плюс ARCHITECTURE.md, rules.md, structure.yaml, pipeline.yaml
stacks/<id>.yaml
policies/<id>.yamlid внутри файла обязан совпадать с именем файла или каталога, иначе load* и list*
разошлись бы в ответах на один и тот же вопрос.
Сломанный шаблон не исчезает. listArchitectures, listStacks и listPolicies возвращают
{ items, errors }: файл, который не проходит схему, попадает в errors вместе с абсолютным
путём и полями, на которых он развалился, а остальные шаблоны остаются в items. Выбрать его
нельзя — применять нечего; loadArchitecture, loadStack и loadPolicy, которых просят по
имени, по-прежнему бросают AimanaValidationError.
const { items, errors } = await listStacks(createTemplateResolver({ root }));
// errors: [{ id, source, file, message, issues: [{ path, message }] }]Обход каталога не уходит по симлинкам за его пределы: сам каталог шаблонов симлинком быть
может (~/.aimana/templates в дотфайлах — обычное дело), а отдельная запись внутри него,
ведущая наружу, не читается и попадает в errors.
Шаблоны архитектур
Каталог templates/architectures/<id>/ — каркас архитектуры, который разворачивается в
проект. Ищется по той же цепочке (проект → пользователь → встроенные), причём пофайлово:
чтобы поменять только правила встроенного шаблона, достаточно положить свой
.aimana/templates/architectures/hexagonal/rules.md.
architectures/<id>/
template.yaml id, title, description, tags — манифест, он же объявляет шаблон
ARCHITECTURE.md каркас с заголовками и вопросами, уезжает в .aimana/ARCHITECTURE.md
rules.md правила, которые дописываются в PROJECT.md
structure.yaml (необязательно) рекомендуемое дерево папок: entries[{path, note}]
pipeline.yaml (необязательно) пайплайн, который шаблон предлагает вместо дефолтногоВстроенных восемь: modular-monolith, clean-architecture, hexagonal, feature-sliced,
microservices, mobile-mvvm, cli-tool, library.
import {
AimanaFs,
applyArchitecture,
createTemplateResolver,
listArchitectures,
previewArchitecture,
} from '@aimana/core';
const resolver = createTemplateResolver({ root });
await listArchitectures(resolver);
// { items: [{ id, title, description?, tags, source }], errors: [] }
const preview = await previewArchitecture(new AimanaFs(root), resolver, 'clean-architecture');
// preview.architecture / preview.rules: { action, current, next, diff } — ничего не записано
await applyArchitecture(new AimanaFs(root), resolver, 'clean-architecture', {
overwriteArchitecture: preview.overwritesArchitecture,
});Правила шаблона живут в теле PROJECT.md между маркерами:
<!-- aimana:template rules start -->
...правила шаблона...
<!-- aimana:template rules end -->Граница нужна, чтобы смена шаблона убирала ровно правила прежнего и не трогала дописанное руками. Одинокий маркер без пары считается отсутствующей секцией: гадать, где кончался наполовину стёртый блок, значит съесть чужой текст.
ARCHITECTURE.md, написанный человеком, без спроса не перезаписывается: applyArchitecture
бросает AimanaConflictError, пока не передан overwriteArchitecture. Дифф строк считает
diffLines — свой LCS на тридцать строк, ради двух markdown-файлов зависимость не заводится.
Пресеты стеков
Файл templates/stacks/<id>.yaml — пресет стека: чем проект написан, какими командами
проверяется и по каким признакам его узнать. Ищется по той же цепочке
(проект → пользователь → встроенные), побеждает ближайший к проекту.
id: next
title: Next.js
description: 'React на сервере и в браузере: маршруты по файлам…'
language: typescript
framework: nextjs
package_manager: npm
platforms: [web]
gates:
lint: 'npm run lint'
typecheck: 'npx tsc --noEmit'
build: 'npm run build'
rules:
- 'Компонент серверный по умолчанию…'
detect:
files: [next.config.ts]
dependencies: [next]
contains: [{ file: pom.xml, text: spring-boot-starter }]
priority: 20Встроенных восемнадцать: next, remix, vite-react, nest, fastify, express,
django, fastapi, spring-boot, quarkus, go-std, rust-axum, swiftui,
kotlin-compose, flutter, react-native, electron, tauri.
Гейт есть только тогда, когда у стека есть настоящая команда. У Next нет тестового
раннера из коробки — гейта test в пресете нет; у Express нет ни линтера, ни типов —
остаётся один npm test. Выдуманная команда падает на первом запуске и учит человека
не верить гейтам, поэтому её лучше не писать вовсе. Имена гейтов не ограничены четвёркой:
у Django есть migrations, у Rust и Flutter — format:check.
Автодетект
import { createTemplateResolver, detectStackPreset } from '@aimana/core';
const matches = await detectStackPreset(root, createTemplateResolver({ root }));
// [{ preset, score, signals: [{ kind: 'file', text: 'next.config.ts' }, …] }] — лучший первымПризнак срабатывает, если файл есть (* матчит внутри одного сегмента, поэтому
*.xcodeproj работает), если имя есть среди зависимостей package.json (точное совпадение,
иначе react объявил бы React Native реактом) или если в файле встречается строка
(без учёта регистра — в requirements.txt пишут Django). Ранжирование — по числу
сработавших признаков, при равенстве по detect.priority: приложение на Electron с Vite
даёт два признака обоим пресетам, и специфичный должен побеждать. Пустой ответ — это
ответ, а не ошибка: большинство репозиториев написаны на стеке, под который пресета нет.
Битый пресет в .aimana/templates/stacks/ при этом всё равно бросает AimanaValidationError.
Применение
import { applyStackPreset, previewStackPreset } from '@aimana/core';
const preview = await previewStackPreset(new AimanaFs(root), resolver, 'django');
// preview.settings: [{ field: 'stack.language', current: 'typescript', next: 'python' }, …]
// preview.rules: { action, current, next, diff } — на диск ничего не записано
await applyStackPreset(new AimanaFs(root), resolver, 'django');Пресет пишет stack.* (включая stack.preset), гейты своих имён и правила — всё через
updateProjectSettings и updateProjectBody, то есть документ остаётся валидным по схеме.
Гейт, о котором пресет ничего не говорит, остаётся как был. Платформы добавляются, а не
заменяются: пресет знает про свой стек, но не знает, что у проекта есть ещё и CLI.
Правила пресета живут в своей паре маркеров:
<!-- aimana:stack rules start -->
...правила пресета...
<!-- aimana:stack rules end -->Отдельно от aimana:template rules шаблона архитектуры — иначе выбор стека стирал бы правила
архитектуры и наоборот.
Политики
Файл templates/policies/<id>.yaml — то, во что разворачивается чекбокс
PROJECT.md: policies: текст правила, дополнительные гейты и дополнительные стейджи. Цепочка
та же (проект → пользователь → встроенные), побеждает ближайший к проекту. Идентификатор —
snake_case: это ключ из policies.security или policies.testing, а не kebab-case-id шаблона.
id: dependency_audit
title: Аудит зависимостей
kind: security
description: 'Зависимости проверяются на известные уязвимости.'
rule: 'Новая зависимость приходит вместе с проверкой на известные уязвимости…'
gates:
- name: audit
package_managers:
pnpm: 'pnpm audit --audit-level=high'
cargo: 'cargo audit'
languages:
go: 'go run golang.org/x/vuln/cmd/govulncheck@latest ./...'
stages: []Встроенных семь: input_validation, no_secrets_in_code, dependency_audit, auth_review,
unit_required, integration_required, e2e_required — ровно чекбоксы справочника
catalogue/project-options.ts.
Команда гейта зависит от стека, поэтому её нет одной на всех: pnpm audit ничего не значит
для проекта на cargo. Сначала спрашивается пакетный менеджер, потом язык, и если ни один не
знает команды — policyGates честно возвращает пустой список, а гейт не появляется вовсе.
По той же причине у no_secrets_in_code гейта нет ни для кого: сканеры секретов не входят ни в
один пакетный менеджер, а выдуманная команда падает на первом запуске.
Применение
import { applyPolicies, previewPolicies } from '@aimana/core';
const preview = await previewPolicies(new AimanaFs(root), resolver); // как есть сейчас
await applyPolicies(new AimanaFs(root), resolver, { dependency_audit: true });Включение пишет флаг, гейты политики и её правила — всё через updateProjectSettings и
updateProjectBody. Выключение убирает ровно своё: флаг становится false (ключ остаётся
в файле — это же чекбокс), правило уходит из секции, а гейт снимается только пока его команда
всё ещё одна из тех, что политика могла написать сама. Команду, переписанную руками, выключение
не трогает.
Правила политик живут в третьей паре маркеров:
<!-- aimana:policy rules start -->
...правила включённых политик, их стейджи и гейты...
<!-- aimana:policy rules end -->Отдельно от aimana:template rules и aimana:stack rules: три владельца пишут в один файл, и
общая граница означала бы, что каждый выбор стирает два других. Когда не включено ничего,
секция убирается целиком вместе с маркерами — пустой заголовок хуже отсутствующего.
Дополнительные стейджи
policyStages собирает стейджи включённых политик, withPolicyStages подмешивает их в
пайплайн, по которому планируется таска (стейдж с тем же видом и названием второй раз не
добавляется). Сейчас планировщик демона этим ещё не пользуется: до плана дополнительные
стейджи доезжают текстом — секция «Дополнительные стейджи» в правилах PROJECT.md, а правила
проекта лежат в каждом промпте. Чтобы стейдж появился и в каркасе пайплайна, PlanService
должен позвать withPolicyStages — это одна строка, но она в packages/daemon/src/plans.
