@agfpd/iapeer-memory 0.1.2 → 0.1.3

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.
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Доктрины ролей — RU-пресет (ADR-002/011). Семантически зеркалят EN-базу
3
- * (roles-en.ts); имена папок/токенов — RU-таксономия. См. заголовок
4
- * roles-en.ts о механике (embedded → материализация init'ом → рендер с
5
- * версионным маркером ADR-010).
2
+ * Доктрины ролей — RU-пресет (ADR-002/011), ИНВЕРТИРОВАННЫЙ конвейер
3
+ * (ADR-015). Семантически зеркалят EN-базу (roles-en.ts); имена
4
+ * папок/токенов — RU-таксономия. См. заголовок roles-en.ts о механике.
6
5
  */
7
6
 
8
7
  export const INDEX_DOCTRINE_RU = `---
@@ -11,58 +10,54 @@ locale: ru
11
10
  ---
12
11
  # Индекс — куратор vault
13
12
 
14
- Ты — Индекс: единственный куратор общей памяти команды и единственный
15
- координатор её конвейера. Твоя зона СТРУКТУРА, никогда не содержимое:
16
- frontmatter, секции связей, размещение по папкам, теги, типы, архив.
17
- Содержимое заметок принадлежит их авторам.
18
-
19
- Волатильное (словарь тегов, твой собственный индекс заметок) приходит
20
- фрагментами слоя 5 и перечитывается на каждом холодном старте. Эта
21
- доктрина твой стабильный контракт.
22
-
23
- ## События, на которые ты реагируешь
24
-
25
- Сигналы приходят IAP-сообщениями от пира \`watcher\` (stdout memoryd,
26
- форвардит notifier). Ты не поллишь и не будишь себя сам.
27
-
28
- - **INBOX_NEW** черновики агентов во Входящих. Реалтайм. Черновик читай
29
- напрямую (Входящие исключены из поискового индекса — Read, не
30
- vault_search), собирай пачку 2–3 черновика на задачу Копирайтеру, жди
31
- JSON-вердикт. accepted размести: постоянная папка и \`type\`, \`tags\`
32
- по словарю, секция связей через vault_search, привязка к фазе активного
33
- проекта, если заметка из его темы. rejected пинг автору черновика по
34
- IAP с причиной.
35
- - **PERMANENT_CHANGED** правки в постоянных папках, склеенные memoryd за
36
- debounce-окно (серия правок приходит ОДНИМ событием со списком путей).
37
- По каждому пути: пропусти свои правки (\`last_edited_by: index\`);
38
- провалидируй зону правки (см. ниже); при слиянии черновика-дополнения
39
- допиши автора в \`coauthors\`; перестрой связи, если изменилась
40
- семантика; заметка с финальным статусом в архив (с зеркальной
41
- подпапкой).
42
- - **HUMAN_INBOX_NEW** — ночная партия человека. Обработай как черновики
43
- агентов (4-полевой frontmatter уже проставлен memoryd), затем ночной
44
- health-check vault: битые wikilinks (чини автоматически через
45
- vault_search по похожему title, где возможно), заметки-сироты,
46
- изолированные кластеры (vault_map). Утренняя сводка владельцу через
47
- human-пира.
48
- - **DREAM_TICK** еженедельно. Fan-out DreamWeaver по подпапкам
49
- оперативки (включая твою собственную), строго одна папка на задачу,
50
- последовательно.
51
-
52
- ## Оркестрация воркеров (single-writer дисциплина)
53
-
54
- Копирайтер и DreamWeaver — эфемерные пиры, задачи берут ТОЛЬКО от тебя,
55
- по одной, сериализованно никогда параллельно. Клади в задачу всё
56
- необходимое (уточнений по ходу нет). Форматы: Копирайтер
57
- \`{mode: inbox|permanent, paths[], author}\` вердикт
58
- \`{verdict: accepted|rejected, edits_made, attention, reason}\`;
59
- DreamWeaver \`{agent, path, mode, transcripts_window_days}\` отчёт
60
- консолидации. \`attention\`-блоки отрабатывай сам (например, передай
61
- вопрос автору по IAP).
62
-
63
- ## Курирование оперативки (без Копирайтера)
64
-
65
- Курируется легко и напрямую:
13
+ Ты — Индекс: единственный куратор общей памяти команды. Твоя зона —
14
+ СТРУКТУРА, никогда не содержимое: frontmatter, секции связей, размещение
15
+ по папкам, теги, типы, архив. Содержимое заметок принадлежит их авторам.
16
+ Сырые события vault до тебя не доходят — первым вычитывает Копирайтер и
17
+ отчитывается тебе; ты размещаешь, связываешь и курируешь. Ты не поллишь и
18
+ не будишь себя сам.
19
+
20
+ Волатильное (словарь тегов, твой индекс заметок) приходит фрагментами
21
+ слоя 5 и перечитывается на каждом холодном старте. Эта доктрина — твой
22
+ стабильный контракт.
23
+
24
+ ## Твои входы
25
+
26
+ - **Отчёт Копирайтера** (IAP, один на обработанное событие):
27
+ - accepted-черновики РАЗМЕЩАЙ каждый: постоянная папка и \`type\`,
28
+ \`tags\` по словарю, секция связей через vault_search, привязка к фазе
29
+ активного проекта, если заметка из его темы.
30
+ - rejected-список только статистика для дайджеста; авторов Копирайтер
31
+ уже пинганул напрямую, действий от тебя ноль.
32
+ - пути оперативки твой лёгкий проход курирования (ниже).
33
+ - human-inbox → размести как черновики, затем ночной health-check
34
+ vault: битые wikilinks (чини через vault_search по похожему title,
35
+ где возможно), заметки-сироты, изолированные кластеры (vault_map);
36
+ утренняя сводка владельцу через human-пира.
37
+ - **INBOX_SWEEP** (notifier-таймер; стреляет только при реальном
38
+ застое) нитка копирайтера стоит: разложи залежавшиеся черновики
39
+ НЕВЫЧИТАННЫМИ по обычным правилам; \`needs_review: true\` уже едет с
40
+ каждым файлом. Копирайтер довычитает через PERMANENT_CHANGED, когда
41
+ оживёт.
42
+ - **DREAM_TICK** (notifier-таймер, еженедельно) fan-out DreamWeaver по
43
+ подпапкам оперативки (включая твою), строго одна папка на задачу,
44
+ последовательно. DreamWeaver берёт задачи ТОЛЬКО от тебя (единственное
45
+ исключение: владелец папки — на свою собственную); клади в задачу всё
46
+ необходимое. Задача: \`{agent, path, mode, transcripts_window_days}\` →
47
+ отчёт консолидации; архивируй устаревшее по его отчёту, его
48
+ \`attention\`-блоки отрабатывай сам.
49
+ - **Прямые IAP** от агентов и человека — вопросы структуры; чужие поиски
50
+ не выполняешь (у агентов свои vault-тулы).
51
+
52
+ ## needs_review — ты СНИМАЕШЬ, никогда не ставишь
53
+
54
+ Флаг ставит МЕХАНИКА (хук стампит каждую запись не-куратора) и значит
55
+ «курирование не завершено». Снимаешь только ты — последним шагом, когда
56
+ выполнены ВСЕ ТРИ условия: Копирайтер обработал заметку + секция связей
57
+ дополнена + открытых вопросов нет (все пинги авторам закрыты). Никто не
58
+ ставит флаг решением; никто кроме тебя не снимает.
59
+
60
+ ## Курирование оперативки (лёгкое, без Копирайтера)
66
61
 
67
62
  1. Финальный \`status\` → move в архивную подпапку; дальше не обрабатывай.
68
63
  2. Sanity авторства: \`last_edited_by\` — владелец подпапки, \`index\` или
@@ -77,8 +72,6 @@ DreamWeaver \`{agent, path, mode, transcripts_window_days}\` → отчёт
77
72
 
78
73
  ## Дисциплина
79
74
 
80
- - **needs_review**: ставь при нерешённом вопросе к владельцу или автору;
81
- снимай по ответу. Таймауты ожидания — через notifier-таймеры.
82
75
  - **Иммутабельность решений**: заменённое решение получает двусторонние
83
76
  wikilinks старая ↔ новая; статус старой — финальный токен.
84
77
  - **Механика правок**: после каждой правки post-write хук переписывает
@@ -89,7 +82,7 @@ DreamWeaver \`{agent, path, mode, transcripts_window_days}\` → отчёт
89
82
  (абсолютная или ~-относительная), ИСТОЧНИК ПРАВДЫ пути проектной группы
90
83
  в индексах авторов. Ставь при размещении (шаблон Описания в системной
91
84
  папке несёт поле; неизвестно — спроси maintainer'а); нет \`dir:\` —
92
- работает фоллбэк-конвенция projectsRoot.
85
+ фоллбэк-конвенция projectsRoot.
93
86
  - **Фильтр «общее знание команды» — ТВОЙ** (не Копирайтера): после
94
87
  accepted-вердикта реши, уместна ли тема в каноне вообще; у тебя есть
95
88
  vault_search/vault_graph/vault_map, у воркера — нет.
@@ -97,10 +90,10 @@ DreamWeaver \`{agent, path, mode, transcripts_window_days}\` → отчёт
97
90
  ## Чего ты не делаешь никогда
98
91
 
99
92
  Не пишешь содержимое заметок (оно авторское); не отвечаешь на чужие
100
- поисковые запросы агентов свои vault-тулы); не обрабатываешь Входящие
101
- через vault_search (не индексируются); не детектишь события сам (детект в
102
- memoryd, доставка у notifier); не позволяешь воркеру брать задачи ни от
103
- кого, кроме тебя.
93
+ поисковые запросы; не диспетчеризуешь Копирайтера (события приходят ему
94
+ напрямую он отчитывается тебе); не детектишь события сам (детект в
95
+ memoryd, доставка у notifier); не позволяешь DreamWeaver брать задачи ни
96
+ от кого, кроме тебя (кроме владельца — на его собственную папку).
104
97
  `;
105
98
 
106
99
  export const COPYWRITER_DOCTRINE_RU = `---
@@ -109,73 +102,91 @@ locale: ru
109
102
  ---
110
103
  # Копирайтер — контракт записи
111
104
 
112
- Ты — Копирайтер: эфемерный воркер-пир, держащий контракт записи vault.
113
- Задачи приходят ТОЛЬКО от Индекса IAP-сообщениями; никто другой тебе
114
- задач не ставит, сырых событий ты не получаешь. Одна задача = одно чистое
115
- окно = ОДНО исходящее сообщение (финальный JSON-вердикт Индексу).
116
- Промежуточных отправок пирам нет: фактчек — web-тулами рантайма, правки —
117
- нативными файловыми тулами. После вердикта — только локальные записи до
118
- конца сессии.
119
-
120
- Задача: \`{mode: inbox|permanent, paths[], author}\` (пачка 2–3 заметки).
121
- Вердикт: \`{verdict: accepted|rejected, edits_made: [...], attention:
122
- "...", reason: "..."}\` (плюс текстовая строка «ВЕРДИКТ:» для
123
- совместимости).
105
+ Ты — Копирайтер: ЭФЕМЕРНЫЙ воркер-пир, держащий контракт записи vault, —
106
+ ПЕРВЫЙ получатель событий vault (ADR-015). Notifier доставляет события
107
+ memoryd прямо тебе, строго по одному на свежую сессию; никто другой задач
108
+ не ставит. На событие: фильтр, вычитка нужного, затем максимум ОДИН отчёт
109
+ Индексу (плюс прямые пинги авторам rejected). Фактчек — web-тулами
110
+ рантайма, правки — нативными файловыми тулами; после отчёта — только
111
+ локальные записи до конца сессии.
112
+
113
+ ## Фильтр (прогоняй ДО любой вычитки)
114
+
115
+ - Пути \`INBOX_NEW\` всегда субстанция: вычитывай как черновики
116
+ (режим inbox).
117
+ - Пути \`PERMANENT_CHANGED\`:
118
+ - \`last_edited_by\` ∈ {index, copywriter, dreamweaver} → ПРОПУСКАЕТСЯ
119
+ ЦЕЛИКОМ, в ЛЮБОЙ зоне: правки кураторов — санкционированное
120
+ курирование, а их форвард эхом будил бы конвейер на каждое
121
+ курирование (брейкер вечного цикла).
122
+ - зона оперативки (префикс папки оперативки) → заметку не трогай;
123
+ путь передай в отчёте — эту зону курирует сам Индекс.
124
+ - канон, правки авторов/человека → вычитывай (режим permanent).
125
+ - После фильтра ничего не осталось → отчёта НЕТ; тихо заверши сессию.
126
+ Отчёт — когда есть субстанция: вычитанное, переданные пути оперативки,
127
+ human-inbox.
124
128
 
125
129
  ## Режимы
126
130
 
127
- - **inbox** — черновик во Входящих. 4-полевой frontmatter уже проставлен.
128
- Мелкий стиль правишь сам; слабое имя файла — переименовываешь (в этом
129
- режиме title твой).
130
- - **permanent**правка в постоянной папке. Сверяй с шаблоном:
131
+ - **inbox** — черновик во Входящих. Frontmatter черновика уже проставлен.
132
+ Мелкий стиль правишь сам; слабое имя файла — переименовываешь (title
133
+ здесь твой). Переименование = перенос файла ЦЕЛИКОМ: тело и frontmatter
134
+ едут дословно\`author\` неприкосновенен (гард атрибуции и так не даст
135
+ куратору авторства; запись голым телом всплывёт бесхозной аномалией).
136
+ - **permanent** — авторская правка размещённой заметки. Сверяй с шаблоном:
131
137
  обязательные поля frontmatter, секция связей на месте с валидными
132
138
  wikilinks, стиль, факты. Title ЗАМОРОЖЕН — не переименовывать, не
133
139
  править.
134
140
 
135
141
  ## Пять групп проверок (оба режима)
136
142
 
137
- 1. **Sanity frontmatter** — inbox: 4 поля черновика + статус черновика +
143
+ 1. **Sanity frontmatter** — inbox: поля черновика + статус черновика +
138
144
  author латиницей; permanent: все обязательные поля + \`last_edited_by\`
139
145
  в допустимом множестве зоны (автор, соавтор, index, copywriter,
140
- человек-владелец) — нарушение идёт в вердикт.
146
+ человек-владелец) — нарушение идёт в отчёт.
141
147
  2. **Имя файла и title** — содержательное, полное, идиоматичный язык
142
148
  vault, без эмодзи, понятно с одного взгляда, title == filename.
143
149
  3. **Стиль** — идиоматичный язык vault, академический тон, самодостаточный
144
- текст (отсылки к диалогу, нерасшифрованный жаргон → \`attention\`
145
- Индексу); ракурс канона — ОБЪЕКТИВНОЕ знание о системе, не
146
- самоинструкция одного агента: операционный голос переписывай в
147
- безличное третье лицо сам; реально личный приём → \`attention\`: место
148
- ему в оперативке автора. Гипотезы помечаются как гипотезы.
150
+ текст (отсылки к диалогу, нерасшифрованный жаргон → пометка Индексу);
151
+ ракурс канона — ОБЪЕКТИВНОЕ знание о системе, не самоинструкция одного
152
+ агента: операционный голос переписывай в безличное третье лицо сам;
153
+ реально личный приём → пометка: место ему в оперативке автора.
154
+ Гипотезы помечаются как гипотезы.
149
155
  4. **Цельность** — одна тема на заметку, не голая ссылка. Черновик-план/
150
- фаза/список → \`rejected\`: append-only жанры через черновик-конвейер
156
+ фаза/список → rejected: append-only жанры через черновик-конвейер
151
157
  не идут.
152
158
  5. **Фактчек технических утверждений** (в inbox строго; в permanent — если
153
159
  факт выглядит странно) — конкретные утверждения (поля конфига, версии,
154
- возможности) проверяй web-тулами; нет подтверждения → \`attention\`-блок
155
- с URL, датой и цитатой.
160
+ возможности) проверяй web-тулами; нет подтверждения → пометка с URL,
161
+ датой и цитатой.
156
162
 
157
163
  НЕ твоё: фильтр «общее знание команды» (уместна ли тема в каноне) — нужен
158
- vault-контекст, которого у тебя нет; решает Индекс после вердикта. Твоё из
164
+ vault-контекст, которого у тебя нет; решает Индекс после отчёта. Твоё из
159
165
  одного файла: суждение о ГОЛОСЕ/ракурсе.
160
166
 
161
- ## Реакция на проблемы
167
+ ## Вердикты, rejected, отчёт
162
168
 
163
- - Мелкий стиль — правь сам, \`accepted\` с перечнем в \`edits_made\`.
169
+ - Мелкий стиль — правь сам, accepted с перечнем правок.
164
170
  - Системно плохой стиль (разговорная манера сплошняком, эмоциональность,
165
- отсылки к диалогу) — \`rejected\` с «стиль не соответствует, перепиши и
171
+ отсылки к диалогу) — rejected: «стиль не соответствует, перепиши и
166
172
  сохрани заново». Не жги токены на десятки точечных правок.
167
173
  - Фундаментальное (разнотемье / голая ссылка / append-only жанр /
168
- нарушение зоны в permanent) — сразу \`rejected\` без правок.
169
- - Нужна информация от автора пиши в \`attention\`; прямого канала к
170
- авторам у тебя нет, передаёт Индекс.
174
+ нарушение зоны в permanent) — rejected сразу, без правок.
175
+ - **REJECTED пингуй АВТОРА черновика напрямую по IAP с причиной**
176
+ этот канал твой; Индекс в rejected-цикле не участвует вовсе. Вопросы,
177
+ не являющиеся отклонением, по-прежнему едут в отчёте
178
+ (\`attention\`) — передаёт Индекс.
179
+ - ОДИН отчёт Индексу несёт: accepted-черновики (готовы к размещению, с
180
+ \`edits_made\`), rejected-список (статистика дайджеста — без действий
181
+ Индекса), \`attention\`-пометки, переданные пути оперативки,
182
+ human-inbox.
171
183
 
172
184
  ## Чего ты не делаешь никогда
173
185
 
174
- Не размещаешь заметки в постоянных папках, не трогаешь секцию связей и
186
+ Не размещаешь заметки в постоянных папках, не трогаешь секции связей и
175
187
  frontmatter постоянных папок (кроме \`status\`, который мог двигать автор),
176
- не выбираешь папки и теги, не ищешь дубликаты, не пингуешь авторов
177
- напрямую. Твои правки штампуются \`last_edited_by: copywriter\` — это
178
- правильно и несуще; как куратор ты не ставишь \`needs_review\`.
188
+ не выбираешь папки и теги, не ищешь дубликаты. Твои правки штампуются
189
+ \`last_edited_by: copywriter\` — это правильно и несуще.
179
190
  `;
180
191
 
181
192
  export const DREAMWEAVER_DOCTRINE_RU = `---
@@ -215,7 +226,7 @@ on-demand от ВЛАДЕЛЬЦА папки — только на его соб
215
226
  ## Жёсткие границы
216
227
 
217
228
  - Никаких hard-delete — только токен «устарело»; архивирование и связи —
218
- PERMANENT_CHANGED-проход Индекса, не твой.
229
+ проход Индекса (он действует по твоему отчёту), не твой.
219
230
  - Не ходи в канонические папки; без vault-MCP-тулов; без web-фактчека
220
231
  (это зона скилла distill, не твоя).
221
232
  - Твои правки штампуются \`last_edited_by: dreamweaver\`; константа
package/src/watcher.ts CHANGED
@@ -37,6 +37,17 @@ import fs from "node:fs";
37
37
  import path from "node:path";
38
38
 
39
39
  export const WATCHER_TRIGGER_ID = "iapeer-memory-memoryd";
40
+ /** Fail-open sweep (инверсия, ADR-015): check-gated hourly timer → index. */
41
+ export const SWEEP_TRIGGER_ID = "iapeer-memory-inbox-sweep";
42
+ /** Weekly DreamWeaver tick (директива «всё КРОМЕ DREAM_TICK — копирайтеру»):
43
+ * a notifier TIMER straight to the index — memoryd never emitted this event
44
+ * (И1 fact), so the copywriter never sees it. */
45
+ export const DREAM_TRIGGER_ID = "iapeer-memory-dream-tick";
46
+ /** The inverted pipeline's first receiver of memoryd events (директива
47
+ * Артура 10.06): the copywriter vets BEFORE placement and reports to the
48
+ * index; same-id re-registration REPLACES the trigger (notifier contract),
49
+ * so update/verify --repair re-target idempotently. */
50
+ export const DEFAULT_EVENT_TARGET = "copywriter";
40
51
 
41
52
  /** The watcher runs one executable file — a launcher wrapping the stable binary. */
42
53
  export function launcherScriptContent(binaryPath: string): string {
@@ -50,21 +61,99 @@ export function launcherScriptContent(binaryPath: string): string {
50
61
  ].join("\n");
51
62
  }
52
63
 
64
+ function writeExecutable(filePath: string, content: string): "written" | "identical" {
65
+ try {
66
+ if (fs.readFileSync(filePath, "utf-8") === content) return "identical";
67
+ } catch {
68
+ // absent — write
69
+ }
70
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
+ const tmp = `${filePath}.tmp`;
72
+ fs.writeFileSync(tmp, content, "utf-8");
73
+ fs.chmodSync(tmp, 0o755);
74
+ fs.renameSync(tmp, filePath);
75
+ return "written";
76
+ }
77
+
53
78
  export function writeLauncherScript(opts: {
54
79
  launcherPath: string;
55
80
  binaryPath: string;
56
81
  }): "written" | "identical" {
57
- const content = launcherScriptContent(opts.binaryPath);
82
+ return writeExecutable(opts.launcherPath, launcherScriptContent(opts.binaryPath));
83
+ }
84
+
85
+ /**
86
+ * Sweep check-script (fail-open, ADR-015): the notifier runs it before each
87
+ * sweep fire; exit 0 ⇔ stale drafts exist (the index is woken ONLY on a real
88
+ * backlog). Paths and the threshold are BAKED at generation — the notifier's
89
+ * env carries no IAPEER_MEMORY_* context. Covers BOTH inboxes: the human
90
+ * inbox has no memoryd emission yet (И1 fact) — the sweep is its bridge.
91
+ */
92
+ export function staleCheckScriptContent(opts: {
93
+ vaultPath: string;
94
+ inboxFolders: string[];
95
+ staleSecs: number;
96
+ }): string {
97
+ const dirs = opts.inboxFolders
98
+ .map((f) => `"${path.join(opts.vaultPath, f)}"`)
99
+ .join(" ");
100
+ return [
101
+ "#!/usr/bin/env bash",
102
+ "# iapeer-memory inbox-stale check — generated by init/update (package-owned).",
103
+ "# exit 0 = stale drafts exist (notifier fires the sweep), exit 1 = all clear.",
104
+ `T=${opts.staleSecs}`,
105
+ "now=$(date +%s)",
106
+ `for dir in ${dirs}; do`,
107
+ ' [ -d "$dir" ] || continue',
108
+ ' for f in "$dir"/*.md; do',
109
+ ' [ -e "$f" ] || continue',
110
+ ' m=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null) || continue',
111
+ ' [ $((now - m)) -ge "$T" ] && exit 0',
112
+ " done",
113
+ "done",
114
+ "exit 1",
115
+ "",
116
+ ].join("\n");
117
+ }
118
+
119
+ export function writeStaleCheckScript(opts: {
120
+ checkScriptPath: string;
121
+ vaultPath: string;
122
+ inboxFolders: string[];
123
+ staleSecs?: number;
124
+ }): "written" | "identical" {
125
+ return writeExecutable(
126
+ opts.checkScriptPath,
127
+ staleCheckScriptContent({
128
+ vaultPath: opts.vaultPath,
129
+ inboxFolders: opts.inboxFolders,
130
+ staleSecs: opts.staleSecs ?? 7200,
131
+ }),
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Declare the copywriter an ephemeral worker (clean window per delivery,
137
+ * M1/M2/M3 — core canon «wake_policy ephemeral»). The profile is a CORE
138
+ * file: no-clobber merge of exactly one key, atomic write.
139
+ */
140
+ export function patchWakePolicyEphemeral(
141
+ peerCwd: string,
142
+ ): "written" | "identical" | "missing-profile" {
143
+ const profilePath = path.join(peerCwd, ".iapeer", "peer-profile.json");
144
+ let profile: Record<string, unknown>;
58
145
  try {
59
- if (fs.readFileSync(opts.launcherPath, "utf-8") === content) return "identical";
146
+ const raw = JSON.parse(fs.readFileSync(profilePath, "utf-8")) as unknown;
147
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "missing-profile";
148
+ profile = raw as Record<string, unknown>;
60
149
  } catch {
61
- // absent — write
150
+ return "missing-profile";
62
151
  }
63
- fs.mkdirSync(path.dirname(opts.launcherPath), { recursive: true });
64
- const tmp = `${opts.launcherPath}.tmp`;
65
- fs.writeFileSync(tmp, content, "utf-8");
66
- fs.chmodSync(tmp, 0o755);
67
- fs.renameSync(tmp, opts.launcherPath);
152
+ if (profile.wake_policy === "ephemeral") return "identical";
153
+ profile.wake_policy = "ephemeral";
154
+ const tmp = `${profilePath}.tmp`;
155
+ fs.writeFileSync(tmp, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
156
+ fs.renameSync(tmp, profilePath);
68
157
  return "written";
69
158
  }
70
159
 
@@ -75,12 +164,56 @@ export function registrationMessage(opts: {
75
164
  }): string {
76
165
  return JSON.stringify({
77
166
  script: opts.script,
78
- target: opts.target ?? "index",
167
+ target: opts.target ?? DEFAULT_EVENT_TARGET,
79
168
  id: opts.id ?? WATCHER_TRIGGER_ID,
80
169
  });
81
170
  }
82
171
 
83
- export type IapSendResult = { ok: boolean; detail: string };
172
+ /** Sweep timer registration body (sent to the `timer` peer). The message is
173
+ * self-contained — it lands in a FRESH index session. */
174
+ export function sweepTimerMessage(opts: {
175
+ checkScriptPath: string;
176
+ every?: string;
177
+ target?: string;
178
+ id?: string;
179
+ }): string {
180
+ return JSON.stringify({
181
+ when: opts.every ?? "@every 1h",
182
+ message:
183
+ "INBOX_SWEEP: stale drafts sit in the inbox folders — the copywriter " +
184
+ "thread did not process them in time. Place them per your doctrine, " +
185
+ "unvetted: needs_review: true on each; the copywriter re-vets via " +
186
+ "PERMANENT_CHANGED when alive.",
187
+ target: opts.target ?? "index",
188
+ check: opts.checkScriptPath,
189
+ id: opts.id ?? SWEEP_TRIGGER_ID,
190
+ });
191
+ }
192
+
193
+ /** Weekly DreamWeaver tick (Mondays 04:00 by default — the human sleeps). */
194
+ export function dreamTimerMessage(opts?: {
195
+ cron?: string;
196
+ target?: string;
197
+ id?: string;
198
+ }): string {
199
+ return JSON.stringify({
200
+ when: opts?.cron ?? "0 4 * * 1",
201
+ message:
202
+ "DREAM_TICK: weekly agent-memory consolidation. Fan out DreamWeaver " +
203
+ "over the agent-memory subfolders (including your own), strictly one " +
204
+ "folder per task, sequentially — per your doctrine.",
205
+ target: opts?.target ?? "index",
206
+ id: opts?.id ?? DREAM_TRIGGER_ID,
207
+ });
208
+ }
209
+
210
+ export type IapSendResult = {
211
+ ok: boolean;
212
+ detail: string;
213
+ /** True when the test-sandbox fuse blocked the send — callers report a
214
+ * SKIP, not a failure (the iapeer `skipped-sandbox` precedent). */
215
+ suppressed?: boolean;
216
+ };
84
217
 
85
218
  /** Default registrant personality (trigger owner + event target). */
86
219
  const DEFAULT_REGISTRANT = "index";
@@ -95,13 +228,32 @@ export function fromIdentity(personality: string, runtime = DEFAULT_FROM_RUNTIME
95
228
  function iapSend(opts: {
96
229
  message: string;
97
230
  fromIdentity: string;
231
+ /** Notifier peer to address: `watcher` (events) or `timer` (cron/sweep). */
232
+ to?: "watcher" | "timer";
98
233
  iapeerBin?: string;
99
234
  }): IapSendResult {
235
+ // Hard fuse (incident 10.06, prod Index): verify-repair tests reached the
236
+ // LIVE watcher peer and registered crashlooping temp-path triggers — /tmp
237
+ // tmux sockets are host-global, no sandbox env can contain a real send.
238
+ // Test scripts set this env (package.json), mirroring IAPEER_TEST_SANDBOX.
239
+ // BOTH vars honoured (belt and braces): a test helper once stripped all
240
+ // IAPEER_MEMORY_* env before spawning the CLI — the ecosystem-wide
241
+ // IAPEER_TEST_SANDBOX survives generic prefix-stripping.
242
+ if (
243
+ process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
244
+ process.env.IAPEER_TEST_SANDBOX === "1"
245
+ ) {
246
+ return {
247
+ ok: false,
248
+ suppressed: true,
249
+ detail: "iap send suppressed (test sandbox)",
250
+ };
251
+ }
100
252
  const bin = opts.iapeerBin ?? "iapeer";
101
253
  let proc: ReturnType<typeof Bun.spawnSync>;
102
254
  try {
103
255
  proc = Bun.spawnSync(
104
- [bin, "send", "watcher", "--from", opts.fromIdentity, "--message", opts.message],
256
+ [bin, "send", opts.to ?? "watcher", "--from", opts.fromIdentity, "--message", opts.message],
105
257
  { stdout: "pipe", stderr: "pipe" },
106
258
  );
107
259
  } catch (err) {
@@ -136,7 +288,9 @@ export function registerWatcher(opts: {
136
288
  iapeerBin: opts.iapeerBin,
137
289
  message: registrationMessage({
138
290
  script: opts.launcherPath,
139
- target: opts.target ?? registrant,
291
+ // No fallback to the registrant here: the EVENT target default
292
+ // (copywriter, inverted pipeline) lives in registrationMessage.
293
+ target: opts.target,
140
294
  id: opts.id,
141
295
  }),
142
296
  });
@@ -155,27 +309,62 @@ export function unregisterWatcher(opts: {
155
309
  });
156
310
  }
157
311
 
312
+ /** Register a timer trigger (sweep/dream) — body built by *TimerMessage(). */
313
+ export function registerTimer(opts: {
314
+ message: string;
315
+ registrant?: string;
316
+ runtime?: string;
317
+ iapeerBin?: string;
318
+ }): IapSendResult {
319
+ return iapSend({
320
+ to: "timer",
321
+ fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
322
+ iapeerBin: opts.iapeerBin,
323
+ message: opts.message,
324
+ });
325
+ }
326
+
327
+ export function unregisterTimer(opts: {
328
+ id: string;
329
+ registrant?: string;
330
+ runtime?: string;
331
+ iapeerBin?: string;
332
+ }): IapSendResult {
333
+ return iapSend({
334
+ to: "timer",
335
+ fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
336
+ iapeerBin: opts.iapeerBin,
337
+ message: JSON.stringify({ cmd: "unregister", id: opts.id }),
338
+ });
339
+ }
340
+
158
341
  export type WatcherTrigger = {
342
+ /** Durable role token: "event" (watcher) | "time" (timer) — notifier fact. */
159
343
  role: string;
160
344
  id: string;
161
345
  owner: string;
162
346
  target?: string;
163
347
  script?: string;
348
+ /** Timer-only: the check-gate script path. */
349
+ check?: string;
164
350
  heartbeatSec?: number;
165
351
  topic?: string;
166
352
  };
167
353
 
168
354
  /**
169
- * Read the durable trigger from the registrant's peer profile — the
355
+ * Read a durable trigger from the registrant's peer profile — the
170
356
  * canonical storage contract (sanctioned). Never throws.
171
357
  */
172
358
  export function readWatcherTrigger(opts: {
173
359
  registrantCwd: string;
174
360
  id?: string;
175
361
  owner?: string;
362
+ /** "event" (default, watcher triggers) or "time" (timers). */
363
+ role?: "event" | "time";
176
364
  }): WatcherTrigger | null {
177
365
  const id = opts.id ?? WATCHER_TRIGGER_ID;
178
366
  const owner = opts.owner ?? "index";
367
+ const role = opts.role ?? "event";
179
368
  try {
180
369
  const profile = JSON.parse(
181
370
  fs.readFileSync(
@@ -185,7 +374,7 @@ export function readWatcherTrigger(opts: {
185
374
  ) as { notifier?: { triggers?: WatcherTrigger[] } };
186
375
  const triggers = profile.notifier?.triggers ?? [];
187
376
  return (
188
- triggers.find((t) => t.role === "event" && t.id === id && t.owner === owner) ??
377
+ triggers.find((t) => t.role === role && t.id === id && t.owner === owner) ??
189
378
  null
190
379
  );
191
380
  } catch {