@agfpd/iapeer-memory 0.1.1

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/src/roles.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Roles manifest — the init→verify bridge (`<state>/roles.json`):
3
+ * which role peers exist, where their cwd is, which template renders their
4
+ * doctrine. init writes it after `iapeer create`; verify reads it to
5
+ * compare rendered doctrine versions against the package (ADR-010) and
6
+ * `--repair` re-renders from the referenced templates.
7
+ *
8
+ * Role peer location: the CORE'S DEFAULT (`iapeer create` without
9
+ * `--path` → its documented default peers folder). Host-specific layouts
10
+ * must never leak into the product (требование Артура, 10.06).
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ export type RoleEntry = { role: string; peerCwd: string; template: string };
17
+
18
+ export type RolesManifest = { roles: RoleEntry[] };
19
+
20
+ export function writeRolesManifest(opts: {
21
+ rolesManifestPath: string;
22
+ roles: RoleEntry[];
23
+ }): void {
24
+ fs.mkdirSync(path.dirname(opts.rolesManifestPath), { recursive: true });
25
+ const tmp = `${opts.rolesManifestPath}.tmp`;
26
+ fs.writeFileSync(
27
+ tmp,
28
+ JSON.stringify({ roles: opts.roles } satisfies RolesManifest, null, 2) + "\n",
29
+ "utf-8",
30
+ );
31
+ fs.renameSync(tmp, opts.rolesManifestPath);
32
+ }
33
+
34
+ /** Never throws: absent/malformed → null (verify treats it as "init has not run"). */
35
+ export function readRolesManifest(rolesManifestPath: string): RolesManifest | null {
36
+ try {
37
+ const parsed = JSON.parse(fs.readFileSync(rolesManifestPath, "utf-8")) as RolesManifest;
38
+ if (!Array.isArray(parsed.roles)) return null;
39
+ return parsed;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
package/src/slot.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Memory-provider slot declaration — the iapeer memory-slot contract (FINAL,
3
+ * iapeer docs fc68c54/e2195a7/c968219). The slot file tells the core that
4
+ * the three public surfaces (layer-5 fragments / MCP tools / daemon under a
5
+ * notifier watcher) are occupied:
6
+ *
7
+ * - the PROVIDER writes and removes the file (our init/uninstall), atomic
8
+ * temp+rename; the core only reads it (absent/unreadable = empty slot);
9
+ * - a slot held by a FOREIGN provider is never touched — explicit refusal
10
+ * (mirror of the core's own init-step refusal);
11
+ * - `version` = the package version (the same single source as the doctrine
12
+ * marker, ADR-010); our `update` re-writes it (P4 obligation);
13
+ * - `heartbeat` (optional) = the absolute path whose mtime memoryd touches —
14
+ * the core may show staleness in `iapeer status`, never acts on it.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ export const SLOT_PROVIDER = "iapeer-memory";
21
+ export const SLOT_PACKAGE = "@agfpd/iapeer-memory";
22
+
23
+ export type MemoryProviderSlot = {
24
+ provider: string;
25
+ package: string;
26
+ version: string;
27
+ registeredAt: string;
28
+ heartbeat?: string;
29
+ };
30
+
31
+ /** Never throws: missing / unreadable / malformed → null (empty slot). */
32
+ export function readSlot(slotPath: string): MemoryProviderSlot | null {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(slotPath, "utf-8")) as MemoryProviderSlot;
35
+ if (!parsed || typeof parsed.provider !== "string") return null;
36
+ return parsed;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export type SlotWriteResult = {
43
+ action: "written" | "identical" | "refused-foreign";
44
+ existing: MemoryProviderSlot | null;
45
+ };
46
+
47
+ export function writeSlot(opts: {
48
+ slotPath: string;
49
+ version: string;
50
+ heartbeat?: string;
51
+ /** Injectable for tests. */
52
+ nowIso?: string;
53
+ }): SlotWriteResult {
54
+ const existing = readSlot(opts.slotPath);
55
+ if (existing && existing.provider !== SLOT_PROVIDER) {
56
+ return { action: "refused-foreign", existing };
57
+ }
58
+ if (
59
+ existing &&
60
+ existing.version === opts.version &&
61
+ existing.heartbeat === opts.heartbeat &&
62
+ existing.package === SLOT_PACKAGE
63
+ ) {
64
+ return { action: "identical", existing }; // idempotent re-init: no churn
65
+ }
66
+ const slot: MemoryProviderSlot = {
67
+ provider: SLOT_PROVIDER,
68
+ package: SLOT_PACKAGE,
69
+ version: opts.version,
70
+ registeredAt: opts.nowIso ?? new Date().toISOString(),
71
+ ...(opts.heartbeat ? { heartbeat: opts.heartbeat } : {}),
72
+ };
73
+ fs.mkdirSync(path.dirname(opts.slotPath), { recursive: true });
74
+ const tmp = `${opts.slotPath}.tmp`;
75
+ fs.writeFileSync(tmp, JSON.stringify(slot, null, 2) + "\n", "utf-8");
76
+ fs.renameSync(tmp, opts.slotPath);
77
+ return { action: "written", existing };
78
+ }
79
+
80
+ export type SlotRemoveResult = "removed" | "absent" | "refused-foreign";
81
+
82
+ /** Uninstall removes ONLY our own declaration; a foreign slot is left intact. */
83
+ export function removeSlot(slotPath: string): SlotRemoveResult {
84
+ const existing = readSlot(slotPath);
85
+ if (!existing) return "absent";
86
+ if (existing.provider !== SLOT_PROVIDER) return "refused-foreign";
87
+ fs.unlinkSync(slotPath);
88
+ return "removed";
89
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Manifest version sync (docs/10 §Версионная синхронизация): propagate the
3
+ * facade `package/package.json` version into every other manifest of the
4
+ * monorepo — `core/package.json` + `adapters/{claude,codex}` plugin
5
+ * manifests. Wired into the npm `version` lifecycle, runnable standalone:
6
+ *
7
+ * bun src/sync-versions.ts
8
+ *
9
+ * Missing manifests are reported and skipped (the adapters land in P2/P5 —
10
+ * the script must not fail before they exist). The MergeMind
11
+ * sync-plugin-version pattern, generalised to N manifests.
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ export type SyncOutcome = { file: string; action: "updated" | "identical" | "missing" };
18
+
19
+ /** Relative (to the monorepo root) manifests that must carry one version. */
20
+ export const SYNC_TARGETS = [
21
+ "core/package.json",
22
+ "adapters/claude/.claude-plugin/plugin.json",
23
+ "adapters/codex/.codex-plugin/plugin.json",
24
+ ] as const;
25
+
26
+ export function syncVersions(opts: {
27
+ rootDir: string;
28
+ version: string;
29
+ targets?: readonly string[];
30
+ }): SyncOutcome[] {
31
+ const targets = opts.targets ?? SYNC_TARGETS;
32
+ const outcomes: SyncOutcome[] = [];
33
+ for (const rel of targets) {
34
+ const file = path.join(opts.rootDir, rel);
35
+ let raw: string;
36
+ try {
37
+ raw = fs.readFileSync(file, "utf-8");
38
+ } catch {
39
+ outcomes.push({ file: rel, action: "missing" });
40
+ continue;
41
+ }
42
+ const manifest = JSON.parse(raw) as Record<string, unknown>;
43
+ if (manifest.version === opts.version) {
44
+ outcomes.push({ file: rel, action: "identical" });
45
+ continue;
46
+ }
47
+ manifest.version = opts.version;
48
+ // 2-space indent + trailing newline — the repo's manifest style.
49
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
50
+ outcomes.push({ file: rel, action: "updated" });
51
+ }
52
+ return outcomes;
53
+ }
54
+
55
+ /**
56
+ * The facade's dependency on the core is an EXACT version pin kept in
57
+ * lockstep by this script (release decision: two npm packages, one shared
58
+ * version). `npm publish` ships the manifest verbatim — it does NOT
59
+ * rewrite the `workspace:*` protocol (only bun/pnpm publish do), so the
60
+ * pin on disk is the published truth. Locally the exact pin still resolves
61
+ * to the workspace copy: bun matches workspace packages against semver
62
+ * ranges, and syncVersions keeps core/package.json at the same version.
63
+ */
64
+ export function syncCoreDependencyPin(opts: {
65
+ packageManifestPath: string;
66
+ version: string;
67
+ }): SyncOutcome {
68
+ const rel = path.basename(opts.packageManifestPath);
69
+ let raw: string;
70
+ try {
71
+ raw = fs.readFileSync(opts.packageManifestPath, "utf-8");
72
+ } catch {
73
+ return { file: rel, action: "missing" };
74
+ }
75
+ const manifest = JSON.parse(raw) as {
76
+ dependencies?: Record<string, string>;
77
+ };
78
+ const deps = manifest.dependencies ?? {};
79
+ if (deps["@agfpd/iapeer-memory-core"] === opts.version) {
80
+ return { file: rel, action: "identical" };
81
+ }
82
+ deps["@agfpd/iapeer-memory-core"] = opts.version;
83
+ manifest.dependencies = deps;
84
+ fs.writeFileSync(
85
+ opts.packageManifestPath,
86
+ JSON.stringify(manifest, null, 2) + "\n",
87
+ "utf-8",
88
+ );
89
+ return { file: rel, action: "updated" };
90
+ }
91
+
92
+ if (import.meta.main) {
93
+ const pkgDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
94
+ const pkg = JSON.parse(
95
+ fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
96
+ ) as { version: string };
97
+ const outcomes = syncVersions({
98
+ rootDir: path.dirname(pkgDir),
99
+ version: pkg.version,
100
+ });
101
+ outcomes.push(
102
+ syncCoreDependencyPin({
103
+ packageManifestPath: path.join(pkgDir, "package.json"),
104
+ version: pkg.version,
105
+ }),
106
+ );
107
+ for (const o of outcomes) {
108
+ console.log(`sync-versions: ${o.action.padEnd(9)} ${o.file}`);
109
+ }
110
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Writer's guide — EN base. The HOST-WIDE layer-5 fragment: every peer of
3
+ * the fleet reads this on every cold wake (ADR-001). Token-frugal by
4
+ * design — bloat here costs the whole team on every session. Source of
5
+ * truth: docs/01–05; the style base is the proven MergeMind guide.
6
+ */
7
+
8
+ export const GUIDE_EN = `# iapeer-memory — the team's shared memory
9
+
10
+ iapeer-memory is the team's shared memory (agents + human): the canon
11
+ (knowledge / decisions / ideas / projects / lists) plus each agent's
12
+ personal memory. You read it and you write it.
13
+
14
+ **Search the memory first.** Someone may have already solved your problem
15
+ and written it down. Use \`vault_search\` (then \`Read\` the note),
16
+ \`vault_graph\` for a note's neighborhood, \`vault_map\` for the global map.
17
+
18
+ **Verify before acting.** A note is a snapshot at write time. Check that
19
+ the function/file/flag still exists — especially before the user acts on
20
+ your recommendation.
21
+
22
+ **On a conflict between memory and observation — trust the observation.**
23
+ Update or deprecate the stale note. This is living memory.
24
+
25
+ ## Write proactively — you don't exist between sessions
26
+
27
+ A session is ephemeral: when it ends, the context is gone. The vault is
28
+ the ONLY thing that survives. Not written down → lost forever.
29
+
30
+ Write without asking permission. A meaningful result (a fix, a decision,
31
+ a pitfall, feedback, a system nuance) is recorded immediately, by you.
32
+ Canon material → a draft in \`00_Inbox/\`; personal material → your own
33
+ agent-memory folder. Asking the human "should I write this down?" is an
34
+ anti-pattern.
35
+
36
+ **Write concisely.** Notes are injected into readers' contexts; bloat
37
+ costs tokens for the whole team.
38
+
39
+ **Sweep as you write.** Added or updated a note → immediately check
40
+ whether an older note on the same topic became stale: flip its \`status\`
41
+ to the final token, or delete it (your own memory notes only, and only
42
+ with zero graph connections — check with \`vault_graph\` first).
43
+
44
+ ## Canon drafts → \`00_Inbox/\`
45
+
46
+ Write the BARE BODY only — no frontmatter, no links section (the post-write
47
+ hook stamps the 4 draft fields; the links section belongs to the Index):
48
+
49
+ Write("<vault>/00_Inbox/<Meaningful title>.md", "<body>")
50
+
51
+ Canon style: idiomatic vault language, academic tone, self-contained text
52
+ (no dialogue references, expand abbreviations on first use), no emoji.
53
+ The canon's viewpoint is objective knowledge about a system — your
54
+ personal "how I do it" belongs in your agent memory instead. Mark
55
+ hypotheses as hypotheses. The filename IS the title readers will see in
56
+ their index — make it understandable without opening the file.
57
+
58
+ ## Your agent memory → \`06_Agent_Memory/<your name>/\`
59
+
60
+ Personal notes useful to you, written directly (no inbox). Frontmatter:
61
+ only \`subtype\` + \`description\`, the hook fills the rest:
62
+
63
+ ---
64
+ subtype: <one of below>
65
+ description: "1–2 sentences on the content"
66
+ ---
67
+ <body>
68
+
69
+ Five \`subtype\` values: \`feedback\` (from colleagues), \`context\`
70
+ (project/task handoff), \`reference\` (your navigation marks and
71
+ procedures), \`person_profile\` (facts about a person), \`pitfall\`
72
+ (a rule born from one incident — stepped on it once, wrote it down).
73
+
74
+ Material that is BOTH team knowledge and personal → do both: a draft in
75
+ \`00_Inbox/\` + a memory note mentioning it inline as \`[[Draft title]]\`.
76
+
77
+ ## Editing rules
78
+
79
+ Edit the BODY of your own notes freely (you are \`author\` or in
80
+ \`coauthors\`) — reword, update, replace stale text in place; no
81
+ "Update YYYY-MM-DD" journals. Additions to FOREIGN notes go through a new
82
+ inbox draft (the Index merges and adds you to \`coauthors\`).
83
+
84
+ From the frontmatter you change ONLY \`status\`, by your note type's scale
85
+ (knowledge/list/memory: current → outdated; idea: new → in_progress /
86
+ dropped; decision: accepted → superseded; project files: active → paused /
87
+ completed; phases: planned → active → completed / paused / cancelled).
88
+ The decision STATEMENT in \`02_Decisions/\` is immutable — supersede it
89
+ with a new draft instead. \`needs_review\` is the Index's field, not yours.
90
+ Never edit the links section, tags, \`type\`, or another agent's memory
91
+ folder.
92
+
93
+ Deletion (\`rm\`) — only in YOUR memory folder and only at zero
94
+ connections (\`vault_graph\` first); otherwise flip \`status\` and let the
95
+ Index archive. Renames — same watershed.
96
+
97
+ ## Projects
98
+
99
+ \`Plan <name>.md\` = the high-level phase list; \`Phase — <title>.md\` =
100
+ that phase's task checklist. Both are append-only: add, don't rewrite
101
+ history. The full Plan text is read with \`Read\` — the path is in your
102
+ index.
103
+ `;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Гайд писателя — RU-пресет. Host-wide фрагмент слоя 5; см. заголовок
3
+ * guide-en.ts. Семантическое зеркало EN-базы с RU-таксономией; стилевая
4
+ * база — живой гайд MergeMind.
5
+ */
6
+
7
+ export const GUIDE_RU = `# iapeer-memory — общая память команды
8
+
9
+ iapeer-memory — общая память команды (агенты + человек): канон
10
+ (знания / решения / идеи / проекты / списки) + личная оперативка каждого
11
+ агента. Ты читаешь и пишешь.
12
+
13
+ **Перед ответом — сначала ищи в памяти.** Кто-то мог решить эту задачу и
14
+ записать. \`vault_search\` (найденное читай \`Read\`), \`vault_graph\` —
15
+ окрестность заметки, \`vault_map\` — глобальная карта.
16
+
17
+ **Проверяй прежде чем действовать.** Заметка — снимок на момент записи.
18
+ Проверь, что функция/файл/флаг существуют сейчас — особенно когда
19
+ пользователь собирается действовать по твоей рекомендации.
20
+
21
+ **При конфликте память vs наблюдение — доверяй наблюдению.** Обнови или
22
+ депрекируй устаревшую заметку. Память живая.
23
+
24
+ ## Пиши проактивно — между сессиями тебя нет
25
+
26
+ Сессия эфемерна: завершится — контекст исчезнет. Vault — единственное,
27
+ что выживает. Не записал → потеряно навсегда.
28
+
29
+ Пиши без спроса. Осмысленный результат (фикс, решение, грабли, фидбек,
30
+ нюанс системы) фиксируется сразу, тобой. Канон — черновиком в
31
+ \`00_Входящие/\`; личное — в свою оперативку. Вопрос человеку «записать
32
+ ли?» — анти-паттерн.
33
+
34
+ **Пиши кратко.** Заметки инжектятся в контекст читателей; раздутость
35
+ стоит токенов всей команде.
36
+
37
+ **Подметай за собой — там же, сразу.** Записал или обновил заметку →
38
+ проверь, не устарела ли прежняя по теме: ставь финальный \`status\` или
39
+ удаляй (только свою оперативку и только при нуле связей — сначала
40
+ \`vault_graph\`).
41
+
42
+ ## Канон-черновики → \`00_Входящие/\`
43
+
44
+ Пиши ГОЛОЕ ТЕЛО — без frontmatter и без секции связей (post-write хук
45
+ проставит 4 поля черновика; секция связей — зона Индекса):
46
+
47
+ Write("<vault>/00_Входящие/<Понятное название>.md", "<тело>")
48
+
49
+ Стиль канона: идиоматичный русский, академический тон, самодостаточный
50
+ текст (без отсылок к диалогу, аббревиатуры расшифровывай при первом
51
+ упоминании), без эмодзи. Ракурс канона — объективное знание о системе;
52
+ личное «как я действую» — в оперативку. Гипотезы помечай как гипотезы.
53
+ Имя файла = title, который читатели увидят в индексе — должно быть понятно
54
+ без открытия файла.
55
+
56
+ ## Твоя оперативка → \`06_Оперативка_агентов/<твоё имя>/\`
57
+
58
+ Личные заметки, полезные тебе, пишутся напрямую (мимо Входящих).
59
+ Frontmatter: только \`subtype\` + \`description\`, остальное дозаполнит хук:
60
+
61
+ ---
62
+ subtype: <одно из ниже>
63
+ description: «Краткое 1–2 предложения о содержимом»
64
+ ---
65
+ <тело>
66
+
67
+ Пять значений \`subtype\`: \`обратная_связь\` (от коллег), \`контекст\`
68
+ (handoff проекта/задачи), \`справка\` (навигационные метки и процедурные
69
+ приёмы), \`профиль_человека\` (факты о человеке), \`грабли\` (правило после
70
+ конкретного инцидента — наступил раз, записал).
71
+
72
+ Материал и для команды, и лично тебе → делай оба: черновик во Входящих +
73
+ заметка в оперативке с inline \`[[Название черновика]]\` в теле.
74
+
75
+ ## Правила правок
76
+
77
+ Тело СВОИХ заметок (ты \`author\` или в \`coauthors\`) правь свободно —
78
+ переформулируй, заменяй устаревшее прямо в тексте; без журналов
79
+ «## Update YYYY-MM-DD». Дополнение к ЧУЖОЙ заметке — новым черновиком во
80
+ Входящих (Индекс сольёт и допишет тебя в \`coauthors\`).
81
+
82
+ Из frontmatter меняешь ТОЛЬКО \`status\` по шкале типа (знание/список/
83
+ оперативка: актуально → устарело; идея: новая → реализуется / отброшена;
84
+ решение: принято → заменено; файлы проекта: активный → на паузе /
85
+ завершён; фазы: запланирована → активная → завершена / на паузе /
86
+ отменена). Утверждение решения в \`02_Решения/\` иммутабельно — замена
87
+ только новым черновиком. \`needs_review\` — поле Индекса, не твоё.
88
+ Секцию связей, теги, \`type\` и чужую оперативку не трогаешь никогда.
89
+
90
+ Удаление (\`rm\`) — только своя оперативка и только при нуле связей
91
+ (сначала \`vault_graph\`); иначе финальный \`status\`, заархивирует Индекс.
92
+ Переименование — тот же водораздел.
93
+
94
+ ## Проекты
95
+
96
+ \`План <имя>.md\` — высокоуровневый список фаз; \`Фаза — <название>.md\` —
97
+ чеклист задач фазы. Оба append-only: дописывай, не переписывай историю.
98
+ Полный текст Плана читается \`Read\` — путь есть в твоём индексе.
99
+ `;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Template registry + materialisation. Content is EMBEDDED in the package
3
+ * (TS constants → bundled into the compiled binary; no fs lookup into an
4
+ * evictable npx cache). init/update MATERIALISE the templates to
5
+ * `<plugins>/iapeer-memory/templates/<locale>/…` — the stable on-disk form
6
+ * the roles manifest points at and `verify --repair` re-renders from.
7
+ *
8
+ * Ownership: unlike the vault 99_System seeds (operator-owned, written
9
+ * once), the materialised templates are PACKAGE-owned runtime artifacts —
10
+ * they follow the package version and are overwritten on change
11
+ * (bytes-compare, no mtime churn).
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import type { LocaleId } from "@agfpd/iapeer-memory-core";
17
+ import { GUIDE_EN } from "./guide-en.js";
18
+ import { GUIDE_RU } from "./guide-ru.js";
19
+ import {
20
+ COPYWRITER_DOCTRINE_EN,
21
+ DREAMWEAVER_DOCTRINE_EN,
22
+ INDEX_DOCTRINE_EN,
23
+ } from "./roles-en.js";
24
+ import {
25
+ COPYWRITER_DOCTRINE_RU,
26
+ DREAMWEAVER_DOCTRINE_RU,
27
+ INDEX_DOCTRINE_RU,
28
+ } from "./roles-ru.js";
29
+
30
+ export const ROLE_NAMES = ["index", "copywriter", "dreamweaver"] as const;
31
+ export type RoleName = (typeof ROLE_NAMES)[number];
32
+
33
+ const ROLES: Record<LocaleId, Record<RoleName, string>> = {
34
+ en: {
35
+ index: INDEX_DOCTRINE_EN,
36
+ copywriter: COPYWRITER_DOCTRINE_EN,
37
+ dreamweaver: DREAMWEAVER_DOCTRINE_EN,
38
+ },
39
+ ru: {
40
+ index: INDEX_DOCTRINE_RU,
41
+ copywriter: COPYWRITER_DOCTRINE_RU,
42
+ dreamweaver: DREAMWEAVER_DOCTRINE_RU,
43
+ },
44
+ };
45
+
46
+ const GUIDES: Record<LocaleId, string> = { en: GUIDE_EN, ru: GUIDE_RU };
47
+
48
+ export function roleDoctrineTemplate(locale: LocaleId, role: RoleName): string {
49
+ return ROLES[locale][role];
50
+ }
51
+
52
+ export function guideText(locale: LocaleId): string {
53
+ return GUIDES[locale];
54
+ }
55
+
56
+ /** Stable on-disk path of a materialised role template. */
57
+ export function roleTemplatePath(
58
+ templatesDir: string,
59
+ locale: LocaleId,
60
+ role: RoleName,
61
+ ): string {
62
+ return path.join(templatesDir, locale, `${role}.md`);
63
+ }
64
+
65
+ export function guideTemplatePath(templatesDir: string, locale: LocaleId): string {
66
+ return path.join(templatesDir, locale, "guide.md");
67
+ }
68
+
69
+ export type MaterialiseResult = { written: string[]; identical: string[] };
70
+
71
+ /** Write the locale's templates to disk (package-owned: overwrite on change). */
72
+ export function materialiseTemplates(opts: {
73
+ templatesDir: string;
74
+ locale: LocaleId;
75
+ }): MaterialiseResult {
76
+ const written: string[] = [];
77
+ const identical: string[] = [];
78
+ const entries: Array<[string, string]> = [
79
+ ...ROLE_NAMES.map(
80
+ (role): [string, string] => [
81
+ roleTemplatePath(opts.templatesDir, opts.locale, role),
82
+ roleDoctrineTemplate(opts.locale, role),
83
+ ],
84
+ ),
85
+ [guideTemplatePath(opts.templatesDir, opts.locale), guideText(opts.locale)],
86
+ ];
87
+ for (const [file, content] of entries) {
88
+ let existing: string | null = null;
89
+ try {
90
+ existing = fs.readFileSync(file, "utf-8");
91
+ } catch {
92
+ existing = null;
93
+ }
94
+ if (existing === content) {
95
+ identical.push(file);
96
+ continue;
97
+ }
98
+ fs.mkdirSync(path.dirname(file), { recursive: true });
99
+ const tmp = `${file}.tmp`;
100
+ fs.writeFileSync(tmp, content, "utf-8");
101
+ fs.renameSync(tmp, file);
102
+ written.push(file);
103
+ }
104
+ return { written, identical };
105
+ }