@agfpd/iapeer 0.2.6 → 0.2.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.6",
4
- "description": "Foundation core for the IAPeer multi-agent ecosystem: identity, registry, storage, codec.",
3
+ "version": "0.2.8",
4
+ "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "iapeer": "bin/iapeer"
@@ -128,7 +128,7 @@ describe('send validation', () => {
128
128
  await register('alpha')
129
129
  await expect(
130
130
  sendMessage({ from: 'claude-alpha', target: 'nobody', message: 'hi', env: env() }),
131
- ).rejects.toThrow(/not in the IAPeer peers index|self/)
131
+ ).rejects.toThrow(/not in the iapeer peers index|self/)
132
132
  })
133
133
  })
134
134
 
@@ -1,4 +1,4 @@
1
- // Canonical constants for the IAPeer foundation.
1
+ // Canonical constants for the iapeer foundation.
2
2
  // Consolidated from inter-agent-protocol/src/lib/constants.ts (wins as-is) and
3
3
  // extended with storage-layer path names (blueprint §1 core/constants).
4
4
 
@@ -131,6 +131,14 @@ export function resolveSockDir(env: NodeJS.ProcessEnv = process.env): string {
131
131
  // === per-peer cwd scope ===
132
132
  export const IAPEER_DIR = '.iapeer'
133
133
  export const PEER_PROFILE_FILE = 'peer-profile.json'
134
+ // Doctrine-fragments layer (Канал A, слой 5): a primitive-owned `.iapeer/fragments/`
135
+ // subdir under BOTH the global root (`~/.iapeer/fragments/`, host-wide) and the
136
+ // per-peer cwd (`<cwd>/.iapeer/fragments/`, per-peer). Holds machine-regenerated
137
+ // `*.md` fragments that an ecosystem primitive writes + rotates itself (e.g.
138
+ // iapeer-memory's guide + per-peer note index) — merged into the system prompt so
139
+ // the context survives compaction, yet kept OUT of the hand-authored IAPEER.md
140
+ // doctrine (two writers, mutual-rollback). See composeSystemPrompt Layer 5.
141
+ export const FRAGMENTS_DIR = 'fragments'
134
142
 
135
143
  // === global scope ~/.iapeer/ ===
136
144
  export const IAP_PLUGIN_DIR = 'iap'
@@ -93,7 +93,7 @@ describe('callTool (no wake passed → Ф1 offline behaviour, never spawns)', ()
93
93
  const caller = resolveCallerFromHeader('claude-boris', readPeersIndex())
94
94
  const r = await callTool(caller, 'send_to_peer', { personality: 'nobody', message: 'hi' })
95
95
  expect(r.isError).toBe(true)
96
- expect(r.content[0].text).toMatch(/not in the IAPeer peers index/)
96
+ expect(r.content[0].text).toMatch(/not in the iapeer peers index/)
97
97
  })
98
98
 
99
99
  test('unknown tool → error', async () => {
@@ -77,7 +77,7 @@ export function resolveCallerFromHeader(value: string | undefined, index: PeersI
77
77
 
78
78
  export function buildSendDescription(index: PeersIndex): string {
79
79
  const lines = [
80
- 'Send a message to a known IAPeer peer through Inter-Agent-Protocol.',
80
+ 'Send a message to a known iapeer peer through Inter-Agent-Protocol.',
81
81
  'Peers can be agents, humans, or services. Runtime is the delivery surface, not the peer type.',
82
82
  'Current delivery supports any runtime endpoint that follows the IAP tmux socket convention. Claude/Codex are built-in local runtimes; external runtimes such as telegram can be exposed by runtime-router packages.',
83
83
  '',
@@ -10,7 +10,7 @@
10
10
  // (the dev-channels "I am using this for local development" Enter, 335-345).
11
11
  // - Spawned-Peer/src/spawner.ts — buildClaudeArgv (759-787: same flags +
12
12
  // optional --resume <uuid>) and findLatestTranscript (522-538: resume uuid).
13
- // - IAPeer/src/lifecycle/index.ts — claudeInputReady / claudeBootDialog /
13
+ // - iapeer/src/lifecycle/index.ts — claudeInputReady / claudeBootDialog /
14
14
  // newestClaudeTranscriptMtime / findLatestClaudeTranscript. This is the
15
15
  // canonical port: the slug is realpath(cwd).replace(/[^a-zA-Z0-9]/g,'-') —
16
16
  // claude encodes EVERY non-alphanumeric char (not just '/'); the old
@@ -130,6 +130,65 @@ describe('composeSystemPrompt — layer 4 (other domains)', () => {
130
130
  })
131
131
  })
132
132
 
133
+ describe('composeSystemPrompt — layer 5 (doctrine fragments)', () => {
134
+ const frag = (over: Partial<PromptDomainBlock>): PromptDomainBlock => ({ domain: 'F', ...over })
135
+
136
+ test('empty/omitted fragments add NOTHING (legacy bytes preserved)', () => {
137
+ const legacy = composeSystemPrompt(base)
138
+ expect(composeSystemPrompt({ ...base, promptFragments: [] })).toBe(legacy)
139
+ expect(composeSystemPrompt({ ...base, promptFragments: undefined })).toBe(legacy)
140
+ })
141
+
142
+ test('a presence-marker fragment (empty halves) emits no text and no dangling separator', () => {
143
+ const legacy = composeSystemPrompt(base)
144
+ expect(composeSystemPrompt({ ...base, promptFragments: [frag({ local: '' })] })).toBe(legacy)
145
+ expect(composeSystemPrompt({ ...base, promptFragments: [frag({ global: '', local: '' })] })).toBe(legacy)
146
+ const out = composeSystemPrompt({
147
+ ...base,
148
+ promptFragments: [frag({ domain: 'EMPTY', local: '' }), frag({ domain: 'REAL', local: 'idx-content\n' })],
149
+ })
150
+ expect(out).toContain('idx-content')
151
+ expect(out).not.toMatch(/\n{4,}/)
152
+ })
153
+
154
+ test('within a fragment stem, global (host-wide guide) precedes local (per-peer index)', () => {
155
+ const out = composeSystemPrompt({
156
+ ...base,
157
+ promptFragments: [frag({ domain: 'MEM', global: 'mem-guide', local: 'mem-index' })],
158
+ })
159
+ expect(out).toContain('mem-guide\nmem-index')
160
+ expect(out.indexOf('mem-guide')).toBeLessThan(out.indexOf('mem-index'))
161
+ })
162
+
163
+ test('layer 5 sits AFTER layer 4 (most-volatile last), separated by a blank line', () => {
164
+ const out = composeSystemPrompt({
165
+ ...base,
166
+ pluginDomains: [{ domain: 'D', global: 'domain-text' }],
167
+ promptFragments: [{ domain: 'MEM', local: 'fragment-text' }],
168
+ })
169
+ expect(out.indexOf('domain-text')).toBeLessThan(out.indexOf('fragment-text'))
170
+ expect(out).toContain('domain-text\n\nfragment-text')
171
+ })
172
+
173
+ test('full 5-layer ordering: YAML → doctrine → registry → domains → fragments', () => {
174
+ const out = composeSystemPrompt({
175
+ ...base,
176
+ peers: [peerA],
177
+ pluginDomains: [{ domain: 'D', global: 'domain-text' }],
178
+ promptFragments: [{ domain: 'MEM', global: 'fragment-text' }],
179
+ })
180
+ const order = [
181
+ out.indexOf('personality:'),
182
+ out.indexOf('Be the implementer.'),
183
+ out.indexOf('## Known peers'),
184
+ out.indexOf('domain-text'),
185
+ out.indexOf('fragment-text'),
186
+ ]
187
+ expect(order.every(i => i >= 0)).toBe(true)
188
+ expect(order).toEqual([...order].sort((a, b) => a - b))
189
+ })
190
+ })
191
+
133
192
  // ─────────────────────────────────────────────────────────────────────────────
134
193
  // gatherPromptInput — FS discovery over ~/.iapeer + <cwd>/.iapeer
135
194
  // ─────────────────────────────────────────────────────────────────────────────
@@ -161,6 +220,17 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
161
220
  // a non-.md file must be ignored
162
221
  writeFileSync(join(localRoot, 'peer-profile.json'), '{}')
163
222
 
223
+ // Layer 5 — fragments/ subdir of both roots: a paired stem (host-wide guide +
224
+ // per-peer index) and a global-only fragment; a non-.md file must be ignored.
225
+ const globalFrags = join(globalRoot, 'fragments')
226
+ const localFrags = join(localRoot, 'fragments')
227
+ mkdirSync(globalFrags, { recursive: true })
228
+ mkdirSync(localFrags, { recursive: true })
229
+ writeFileSync(join(globalFrags, 'iapeer-memory.md'), 'mem-guide\n') // host-wide guide
230
+ writeFileSync(join(localFrags, 'iapeer-memory.md'), 'mem-index\n') // per-peer index
231
+ writeFileSync(join(globalFrags, 'other-primitive.md'), 'other-frag\n')
232
+ writeFileSync(join(localFrags, 'notes.txt'), 'ignored') // non-.md, must be ignored
233
+
164
234
  // Layer 3 — registry at the global root (deliberately UNSORTED on disk)
165
235
  const index = {
166
236
  version: 2,
@@ -207,6 +277,23 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
207
277
  expect(byName.MARKER).toEqual({ domain: 'MARKER', local: '' }) // present but empty
208
278
  })
209
279
 
280
+ test('layer 5: fragments/ subdir scanned, paired by stem (guide global + index local), sorted', () => {
281
+ const input = gather()
282
+ const frags = input.promptFragments ?? []
283
+ expect(frags.map(f => f.domain)).toEqual(['iapeer-memory', 'other-primitive'])
284
+ const byName = Object.fromEntries(frags.map(f => [f.domain, f]))
285
+ expect(byName['iapeer-memory']).toEqual({ domain: 'iapeer-memory', global: 'mem-guide\n', local: 'mem-index\n' })
286
+ expect(byName['other-primitive']).toEqual({ domain: 'other-primitive', global: 'other-frag\n' })
287
+ // the non-.md notes.txt never became a fragment
288
+ expect(frags.map(f => f.domain)).not.toContain('notes')
289
+ })
290
+
291
+ test('layer 5: fragments are NOT confused with the root-level Layer-4 domains', () => {
292
+ const input = gather()
293
+ // the fragments dir itself is a directory, not a `.md` file → never a Layer-4 domain
294
+ expect((input.pluginDomains ?? []).map(d => d.domain)).not.toContain('fragments')
295
+ })
296
+
210
297
  test('layer 3: registry projected to EXACTLY 5 fields, sorted by personality', () => {
211
298
  const input = gather()
212
299
  expect((input.peers ?? []).map(p => p.personality)).toEqual(['arthur', 'boris'])
@@ -237,14 +324,19 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
237
324
  expect(prompt).toContain('aaa-global-only')
238
325
  expect(prompt).toContain('mm-global\nmm-local')
239
326
  expect(prompt).toContain('zzz-local-only')
327
+ // Layer 5 (fragments: guide+index paired, global-only fragment)
328
+ expect(prompt).toContain('mem-guide\nmem-index')
329
+ expect(prompt).toContain('other-frag')
240
330
 
241
331
  const order = [
242
332
  prompt.indexOf('personality: "iapeer"'),
243
333
  prompt.indexOf('LOCAL ROLE'),
244
334
  prompt.indexOf('## Known peers'),
245
335
  prompt.indexOf('aaa-global-only'),
336
+ prompt.indexOf('mem-guide'), // Layer 5 — after every Layer-4 domain
246
337
  ]
247
338
  expect(order).toEqual([...order].sort((a, b) => a - b))
339
+ expect(prompt.indexOf('zzz-local-only')).toBeLessThan(prompt.indexOf('mem-guide'))
248
340
  // marker file produced no content and no blank-block artifact
249
341
  expect(prompt).not.toContain('MARKER')
250
342
  expect(prompt).not.toMatch(/\n{4,}/)
@@ -263,6 +355,7 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
263
355
  expect(input.peerDoctrine).toBe('')
264
356
  expect(input.globalDoctrine).toBeUndefined()
265
357
  expect(input.pluginDomains).toEqual([])
358
+ expect(input.promptFragments).toEqual([]) // no fragments/ subdir → empty layer
266
359
  expect(input.peers).toEqual([])
267
360
  // composes to just the YAML block (+ empty doctrine), no extra layers
268
361
  const prompt = composeSystemPrompt(input)
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { readdirSync, readFileSync, statSync } from 'fs'
12
12
  import { join } from 'path'
13
- import { IAPEER_DIR } from '../core/constants.ts'
13
+ import { FRAGMENTS_DIR, IAPEER_DIR } from '../core/constants.ts'
14
14
  import { publicPeerSummary, readPeersIndex, type PublicPeerSummary } from '../registry/index.ts'
15
15
  import { resolveGlobalRoot } from '../storage/index.ts'
16
16
  import type { ComposePromptInput, PromptDomainBlock } from './types.ts'
@@ -93,11 +93,14 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
93
93
  // jq output — the golden test pins this exactly.
94
94
  const layer12 = yaml + global + input.peerDoctrine
95
95
 
96
- // Layer 3 (registry) and Layer 4 (other domains) are appended ONLY when they
97
- // have content.
96
+ // Layer 3 (registry), Layer 4 (operator/plugin domains) and Layer 5 (primitive
97
+ // doctrine fragments) are appended ONLY when they have content. Layer 5 sits LAST
98
+ // — it is the most volatile (machine-regenerated on every source change), so it
99
+ // never disturbs the stable prefix (YAML + doctrine + registry + domains) above it.
98
100
  const layer3 = renderRegistry(input.peers ?? [])
99
- const layer4 = renderDomains(input.pluginDomains ?? [])
100
- const sections = [layer12, layer3, layer4].filter(section => section.length > 0)
101
+ const layer4 = renderMergedBlocks(input.pluginDomains ?? [])
102
+ const layer5 = renderMergedBlocks(input.promptFragments ?? [])
103
+ const sections = [layer12, layer3, layer4, layer5].filter(section => section.length > 0)
101
104
 
102
105
  // ONE section (no registry, no extra domains) → the legacy bytes, UNTOUCHED —
103
106
  // the golden test pins layer12 exactly (peerDoctrine verbatim, incl. its own
@@ -129,36 +132,42 @@ function renderRegistry(peers: readonly PublicPeerSummary[]): string {
129
132
  }
130
133
 
131
134
  // ─────────────────────────────────────────────────────────────────────────────
132
- // Layer 4 every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root, merged
133
- // general specific (global then local). The merge is ORGANIC: domain stems are
134
- // used only for stable ordering (done by the gatherer), never emitted — custom
135
- // operator files (SPAWNER_INSTRUCTIONS.md, …) flow in as ordinary domains. An
136
- // empty file is a presence marker (Канал B) and contributes no text here.
135
+ // Layers 4 & 5 merged global/local blocks. Shared renderer:
136
+ // Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root (operator /
137
+ // plugin user-settings; SPAWNER_INSTRUCTIONS.md, flow in organically).
138
+ // Layer 5: every `<STEM>.md` pair in .iapeer/fragments/ (primitive-owned,
139
+ // machine-regenerated doctrine fragments iapeer-memory's guide + note index).
140
+ // Both merge general → specific (global then local). The merge is ORGANIC: stems
141
+ // are used only for stable ordering (done by the gatherer), never emitted. An empty
142
+ // file is a presence marker (Канал B) and contributes no text — so a fragment dir
143
+ // that drops to a 0-byte placeholder, or whose file vanishes mid-rotation, yields
144
+ // nothing here rather than a dangling separator.
137
145
  // ─────────────────────────────────────────────────────────────────────────────
138
146
 
139
- function renderDomains(domains: readonly PromptDomainBlock[]): string {
140
- const blocks: string[] = []
141
- for (const d of domains) {
142
- // Per domain: global then local (general → specific), each trimmed of its
147
+ function renderMergedBlocks(blocks: readonly PromptDomainBlock[]): string {
148
+ const out: string[] = []
149
+ for (const b of blocks) {
150
+ // Per stem: global then local (general → specific), each trimmed of its
143
151
  // trailing newlines so a 0-byte marker (or a newline-only file) drops out and
144
- // the spacing is uniform. Halves of one domain are kept tight (single '\n');
145
- // distinct domains are separated by a blank line below.
146
- const halves = [d.global, d.local]
152
+ // the spacing is uniform. Halves of one stem are kept tight (single '\n');
153
+ // distinct stems are separated by a blank line below.
154
+ const halves = [b.global, b.local]
147
155
  .filter((s): s is string => s !== undefined)
148
156
  .map(s => s.replace(/\n+$/, ''))
149
157
  .filter(s => s.length > 0)
150
- if (halves.length > 0) blocks.push(halves.join('\n'))
158
+ if (halves.length > 0) out.push(halves.join('\n'))
151
159
  }
152
- return blocks.join('\n\n')
160
+ return out.join('\n\n')
153
161
  }
154
162
 
155
163
  // ─────────────────────────────────────────────────────────────────────────────
156
164
  // gatherPromptInput — the FS-discovery half: scan ~/.iapeer/*.md (global) and
157
165
  // <cwd>/.iapeer/*.md (local), split IAPEER.md (Layer 2) from every other domain
158
- // (Layer 4), and project the live registry through publicPeerSummary (Layer 3),
159
- // into a ready-to-render ComposePromptInput. Layer-1 identity/host facts are
160
- // supplied by the caller (lifecycle gathers them via sw_vers/hostname/etc).
161
- // This is the ONLY FS-touching part; composeSystemPrompt itself stays pure.
166
+ // (Layer 4), scan the fragments/ subdir of both roots (Layer 5), and project the
167
+ // live registry through publicPeerSummary (Layer 3), into a ready-to-render
168
+ // ComposePromptInput. Layer-1 identity/host facts are supplied by the caller
169
+ // (lifecycle gathers them via sw_vers/hostname/etc). This is the ONLY FS-touching
170
+ // part; composeSystemPrompt itself stays pure.
162
171
  // ─────────────────────────────────────────────────────────────────────────────
163
172
 
164
173
  export interface GatherPromptOptions {
@@ -187,28 +196,51 @@ function readFileIfPresent(path: string): string | undefined {
187
196
  }
188
197
  }
189
198
 
190
- /** `*.md` files directly at a `.iapeer` root (NOT recursing), excluding IAPEER.md
191
- * (Layer 2) the domain candidates for Layer 4. Missing dir [].
192
- * The `.md` test and the doctrine exclusion are CASE-INSENSITIVE: the Layer-2
193
- * read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
194
- * insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
195
- * file ALSO surface as a Layer-4 domain and duplicate the doctrine. Lowercasing
196
- * both also picks up an uppercase-extension `NOTES.MD` (spec: no root MD ignored). */
197
- function listDomainFiles(root: string): string[] {
198
- const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
199
+ /** `*.md` files directly in `dir` (NOT recursing). Missing dir → []. The `.md`
200
+ * test is CASE-INSENSITIVE so an uppercase-extension `NOTES.MD` is picked up too. */
201
+ function listMdFiles(dir: string): string[] {
199
202
  try {
200
- return readdirSync(root, { withFileTypes: true })
201
- .filter(e => {
202
- if (!e.isFile()) return false
203
- const lower = e.name.toLowerCase()
204
- return lower.endsWith('.md') && lower !== doctrineLower
205
- })
203
+ return readdirSync(dir, { withFileTypes: true })
204
+ .filter(e => e.isFile() && e.name.toLowerCase().endsWith('.md'))
206
205
  .map(e => e.name)
207
206
  } catch {
208
207
  return []
209
208
  }
210
209
  }
211
210
 
211
+ /** Layer-4 domain candidates: `*.md` directly at a `.iapeer` root, excluding
212
+ * IAPEER.md (Layer 2). The doctrine exclusion is CASE-INSENSITIVE: the Layer-2
213
+ * read (join(root,'IAPEER.md')) resolves a lowercase `iapeer.md` on a case-
214
+ * insensitive FS (macOS APFS), so a byte-exact exclusion here would let that same
215
+ * file ALSO surface as a Layer-4 domain and duplicate the doctrine. */
216
+ function listDomainFiles(root: string): string[] {
217
+ const doctrineLower = IAPEER_DOCTRINE_FILE.toLowerCase()
218
+ return listMdFiles(root).filter(name => name.toLowerCase() !== doctrineLower)
219
+ }
220
+
221
+ /** Build a list of merged global/local blocks (Layer 4 OR Layer 5) from the
222
+ * union of `*.md` stems present in the global and/or local dir, sorted by
223
+ * filename (plain codepoint order) for a deterministic prompt. `lister` selects
224
+ * the candidate `.md` files — Layer 4 excludes the IAPEER.md doctrine, Layer 5
225
+ * takes every fragment. A read that races a rotation (file removed between listdir
226
+ * and read) → that half is undefined and drops out; a 0-byte file → '' and is
227
+ * dropped by the renderer. */
228
+ function gatherMergedBlocks(
229
+ globalDir: string,
230
+ localDir: string,
231
+ lister: (dir: string) => string[] = listMdFiles,
232
+ ): PromptDomainBlock[] {
233
+ const names = new Set<string>([...lister(globalDir), ...lister(localDir)])
234
+ return [...names].sort().map(name => {
235
+ const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
236
+ const global = readFileIfPresent(join(globalDir, name))
237
+ const local = readFileIfPresent(join(localDir, name))
238
+ if (global !== undefined) block.global = global
239
+ if (local !== undefined) block.local = local
240
+ return block
241
+ })
242
+ }
243
+
212
244
  export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput {
213
245
  const env = opts.env ?? process.env
214
246
  const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
@@ -220,20 +252,19 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
220
252
  const peerDoctrine = readFileIfPresent(join(localRoot, IAPEER_DOCTRINE_FILE)) ?? ''
221
253
 
222
254
  // Layer 4 — union of every non-IAPEER domain present globally and/or locally,
223
- // sorted by filename for a deterministic prompt (plain codepoint order). Empty
224
- // (0-byte) files stay as presence markers — readFileIfPresent returns '', and
225
- // renderDomains drops empty halves, so a marker contributes no text/separator.
226
- const domainNames = new Set<string>([...listDomainFiles(globalRoot), ...listDomainFiles(localRoot)])
227
- const pluginDomains: PromptDomainBlock[] = [...domainNames]
228
- .sort()
229
- .map(name => {
230
- const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
231
- const global = readFileIfPresent(join(globalRoot, name))
232
- const local = readFileIfPresent(join(localRoot, name))
233
- if (global !== undefined) block.global = global
234
- if (local !== undefined) block.local = local
235
- return block
236
- })
255
+ // sorted by filename for a deterministic prompt. Empty (0-byte) files stay as
256
+ // presence markers — readFileIfPresent returns '', and the renderer drops empty
257
+ // halves, so a marker contributes no text/separator.
258
+ const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, listDomainFiles)
259
+
260
+ // Layer 5 — doctrine fragments: every `*.md` in the fragments/ subdir of both
261
+ // roots, merged per stem (global guide → per-peer specifics). Primitive-owned and
262
+ // machine-regenerated; an atomic rotation that briefly removes/replaces a file is
263
+ // tolerated (a racing read just drops that half — no partial bytes, no throw).
264
+ const promptFragments = gatherMergedBlocks(
265
+ join(globalRoot, FRAGMENTS_DIR),
266
+ join(localRoot, FRAGMENTS_DIR),
267
+ )
237
268
 
238
269
  // Layer 3 — peers projected through the shared normalizer, sorted by personality
239
270
  // (determinism even if the on-disk file was hand-edited). `opts.peers` lets a
@@ -257,5 +288,6 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
257
288
  ...(globalDoctrine !== undefined ? { globalDoctrine } : {}),
258
289
  peers,
259
290
  pluginDomains,
291
+ promptFragments,
260
292
  }
261
293
  }
@@ -107,6 +107,72 @@ function ready(identity: string): LaunchResult {
107
107
  return { status: 'READY', identity, process_address: identity }
108
108
  }
109
109
 
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // Exit-cause observability — capture WHY a session's process died, AT THE MOMENT
112
+ // of death. The daemon's 60 s supervise tick only learns of a death post-factum
113
+ // (reaped-gone), by which time the exit code/signal — and often the whole tmux
114
+ // server — is gone (the boris-fresh-style blind spot one level deeper than the
115
+ // supervise log). A tmux `pane-died` hook closes it: it fires the instant the
116
+ // pane's leader process exits (with `remain-on-exit on` retaining the dead pane so
117
+ // `#{pane_dead_status}`/`#{pane_dead_signal}` are populated), logs one logfmt line,
118
+ // then kill-sessions the now-dead pane so the daemon's `has-session` death
119
+ // detection (and the always-on KeepAlive block-watch) stay intact.
120
+ //
121
+ // Scope — verified live on tmux 3.6a (3 death modes + the daemon-reap path):
122
+ // • graceful exit → `dead_status=<code> dead_signal=` (code, no signal)
123
+ // • SIGTERM/SIGKILL/crash to the PROCESS → `dead_status= dead_signal=<name>`
124
+ // • daemon-initiated `kill-session` (idle-reap / self-TTL / stop) does NOT fire
125
+ // pane-died → NO line here (those are already in lifecycle.log — no double-log).
126
+ // IRREDUCIBLE GAP: SIGKILL to the tmux SERVER process itself runs no hook (the
127
+ // event loop is gone); only the daemon's post-factum reaped-gone catches that.
128
+ // ─────────────────────────────────────────────────────────────────────────────
129
+
130
+ /** The exit-cause log file inside `exitLogDir` (sibling to lifecycle.log). */
131
+ export function exitLogPath(exitLogDir: string): string {
132
+ return `${exitLogDir}/exits.log`
133
+ }
134
+
135
+ /**
136
+ * Build the tmux `pane-died` hook command string (the value of `set-hook -t <id>
137
+ * pane-died <value>`). On the pane leader's death it appends ONE logfmt line —
138
+ * `ts=<ISO> ev=session-exit identity=<id> dead_status=#{…} dead_signal=#{…}`
139
+ * — to `exitLogFile`, then runs a tmux-NATIVE `kill-session` (no shell `tmux`, so
140
+ * it needs no PATH — launchd gives always-on servers a minimal one). Pure (no I/O)
141
+ * so the exact string is unit-testable. Quoting: the `run-shell` arg is wrapped in
142
+ * tmux SINGLE quotes (literal at the tmux layer, still `#{}`-format-expanded) with
143
+ * sh DOUBLE quotes inside — the two levels never collide; `\n`/`$(…)` pass through
144
+ * tmux untouched to sh. `identity`/`exitLogFile` are assumed free of single quotes
145
+ * (runtime-personality identities and the ~/.iapeer/logs path always are). */
146
+ export function exitCauseHook(identity: string, exitLogFile: string): string {
147
+ const line =
148
+ `ts=%s ev=session-exit identity=${identity} ` +
149
+ `dead_status=#{pane_dead_status} dead_signal=#{pane_dead_signal}\\n`
150
+ const log =
151
+ `printf "${line}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${exitLogFile}"`
152
+ return `run-shell '${log}' ; kill-session -t "${identity}"`
153
+ }
154
+
155
+ /** Install the exit-cause observability on a freshly-created session: ensure the
156
+ * exit-log dir exists, turn `remain-on-exit` on (so pane-died can read the dead
157
+ * pane's status/signal) and register the hook. Best-effort — a tmux/FS hiccup
158
+ * here must never fail the launch (observability is never load-bearing). No-op
159
+ * when `exitLogDir` is falsy (a partial/test cfg): `remain-on-exit` stays OFF so
160
+ * behavior is byte-identical to before (and no dead pane can linger un-reaped). */
161
+ function installExitHook(sock: string, identity: string, exitLogDir: string | undefined): void {
162
+ if (!exitLogDir) return
163
+ try {
164
+ mkdirSync(exitLogDir, { recursive: true, mode: 0o700 })
165
+ // remain-on-exit must be ON before the process can die, else pane-died won't
166
+ // retain the dead pane and the status/signal are lost. Set it (and the hook)
167
+ // immediately after new-session — the only un-coverable window is the few ms
168
+ // before this runs, irrelevant for a runtime that takes seconds to initialize.
169
+ tmux(sock, 'set-option', '-t', identity, 'remain-on-exit', 'on')
170
+ tmux(sock, 'set-hook', '-t', identity, 'pane-died', exitCauseHook(identity, exitLogPath(exitLogDir)))
171
+ } catch {
172
+ /* observability is best-effort — never block a wake on a hook-install hiccup */
173
+ }
174
+ }
175
+
110
176
  // ─────────────────────────────────────────────────────────────────────────────
111
177
  // launch — bring up ONE session (runtime-agnostic via the adapter)
112
178
  // ─────────────────────────────────────────────────────────────────────────────
@@ -178,6 +244,14 @@ export const launch: LaunchFn = async (
178
244
  return fail(identity, `tmux new-session failed: ${(start.stderr ?? '').trim() || 'exit ' + start.status}`)
179
245
  }
180
246
 
247
+ // (2.5) Exit-cause observability: a `pane-died` hook that records WHY this
248
+ // session's process dies (status/signal) at the moment of death into
249
+ // <exitLogDir>/exits.log, then kill-sessions the dead pane (so the
250
+ // daemon's has-session death detection + always-on KeepAlive stay intact).
251
+ // Installed ASAP — before pipe-pane — so even a runtime that dies during
252
+ // boot leaves a cause. No-op without cfg.exitLogDir (remain-on-exit off).
253
+ installExitHook(sock, identity, cfg.exitLogDir)
254
+
181
255
  // (3) pipe-pane the session output to the per-identity log.
182
256
  mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 })
183
257
  const paneLog = `${cfg.logDir}/${identity}.log`
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { getAdapter, launch } from './index.ts'
2
+ import { exitCauseHook, exitLogPath, getAdapter, launch } from './index.ts'
3
3
  import { claudeAdapter } from './adapters/claude.ts'
4
4
  import { codexAdapter } from './adapters/codex.ts'
5
5
  import { telegramAdapter } from './adapters/telegram.ts'
@@ -187,6 +187,39 @@ describe('claudeAdapter', () => {
187
187
  })
188
188
  })
189
189
 
190
+ // ─── exit-cause observability: the pane-died hook builder (pure string) ──────
191
+ describe('exitCauseHook (exit-cause observability)', () => {
192
+ const hook = exitCauseHook('claude-iapeer', '/r/logs/iapeer/exits.log')
193
+
194
+ test('exitLogPath → exits.log sibling to lifecycle.log', () => {
195
+ expect(exitLogPath('/r/logs/iapeer')).toBe('/r/logs/iapeer/exits.log')
196
+ })
197
+ test('reads pane_dead_status AND pane_dead_signal (both death classes)', () => {
198
+ // graceful exit populates #{pane_dead_status}; a signal populates #{pane_dead_signal}.
199
+ expect(hook).toContain('dead_status=#{pane_dead_status}')
200
+ expect(hook).toContain('dead_signal=#{pane_dead_signal}')
201
+ })
202
+ test('one logfmt line: ts + ev=session-exit + identity, appended to the exit log', () => {
203
+ expect(hook).toContain('ev=session-exit')
204
+ expect(hook).toContain('identity=claude-iapeer')
205
+ expect(hook).toContain('ts=%s')
206
+ expect(hook).toContain('>> "/r/logs/iapeer/exits.log"')
207
+ expect(hook).toContain('\\n') // literal backslash-n for sh printf, not a real newline
208
+ })
209
+ test('logs BEFORE it reaps: run-shell (sync, no -b) then tmux-native kill-session', () => {
210
+ // run-shell must NOT be backgrounded (-b) — the printf has to finish before the
211
+ // kill tears the server down, else the line is lost to the race (verified live).
212
+ expect(hook).not.toContain('run-shell -b')
213
+ expect(hook.indexOf('run-shell')).toBeLessThan(hook.indexOf('kill-session'))
214
+ // tmux-NATIVE kill-session (no shell `tmux`) → needs no PATH (launchd minimal env).
215
+ expect(hook).toContain('kill-session -t "claude-iapeer"')
216
+ })
217
+ test('quoting: single-quoted run-shell arg (tmux layer) wrapping double-quoted sh', () => {
218
+ expect(hook).toMatch(/run-shell '.*'/)
219
+ expect(hook).not.toContain("''") // no empty/again-collapsed single-quote pair
220
+ })
221
+ })
222
+
190
223
  // ─── Ф-A #2: deliveryMarkers OWNED by the adapter (07.06 refactor) ───────────
191
224
  describe('deliveryMarkers (adapter-owned, was transport PROMPT_GLYPHS)', () => {
192
225
  test('claude: ❯ glyph + paste patterns', () => {
@@ -20,7 +20,7 @@ import { spawnSync } from 'child_process'
20
20
  import { join } from 'path'
21
21
  import { INFRA_RUNTIME_BIN_ENV, isInfraRuntime, resolveSockDir } from '../core/constants.ts'
22
22
  import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
23
- import { peerLogsDir } from '../storage/index.ts'
23
+ import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
24
24
  import { readPeerProfile } from '../identity/index.ts'
25
25
  import { getAdapter, launch } from './index.ts'
26
26
  import type { LaunchConfig, LaunchSpec } from './types.ts'
@@ -102,6 +102,12 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
102
102
  // GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/ — match the plist's
103
103
  // stdout/stderr dir (installAlwaysOnPlist), not per-peer <cwd>/.iapeer/logs/.
104
104
  logDir: peerLogsDir(personality, { env }),
105
+ // Exit-cause log → the shared ~/.iapeer/logs/iapeer (== lifecycle eventLogDir),
106
+ // so an infra peer's self-death is recorded next to lifecycle.log too. The hook
107
+ // also reaps the dead pane: without it remain-on-exit would linger a dead pane,
108
+ // keeping sessionAlive() true so runAlwaysOn block-watches forever and KeepAlive
109
+ // never respawns — the hook prevents that regression as well as logging the cause.
110
+ exitLogDir: pluginLogsDir('iapeer', { env }),
105
111
  env,
106
112
  alwaysOn: true,
107
113
  }
@@ -20,7 +20,7 @@ export type { PublicPeerSummary } from '../registry/index.ts'
20
20
 
21
21
  // ─────────────────────────────────────────────────────────────────────────────
22
22
  // composeSystemPrompt — the layered Канал-A merge (docs/Сборка системного
23
- // промпта — слои и каналы.md). Four layers, general → specific (local overrides
23
+ // промпта — слои и каналы.md). Five layers, general → specific (local overrides
24
24
  // global):
25
25
  // 1. System YAML (identity + host facts) — jq-GOLDEN, byte-for-byte.
26
26
  // 2. iapeer doctrine: ~/.iapeer/IAPEER.md (global) + <cwd>/.iapeer/IAPEER.md (local).
@@ -28,19 +28,27 @@ export type { PublicPeerSummary } from '../registry/index.ts'
28
28
  // 4. Plugin user-settings: every OTHER <DOMAIN>.md at the .iapeer/ root, global
29
29
  // + local merged per domain. Custom files (SPAWNER_INSTRUCTIONS.md, …) flow
30
30
  // in organically here — no special-casing.
31
+ // 5. Doctrine fragments: every `*.md` in the `.iapeer/fragments/` subdir, global
32
+ // (~/.iapeer/fragments/) + local (<cwd>/.iapeer/fragments/) merged per stem.
33
+ // PRIMITIVE-owned, machine-regenerated (iapeer-memory's guide + note index, …)
34
+ // — a dedicated namespace kept OUT of the hand-authored IAPEER.md doctrine so
35
+ // the auto-writer and the human writer never share a file. Sits LAST: the most
36
+ // volatile layer, so edits to it never disturb the stable prefix above.
31
37
  // composeSystemPrompt is a PURE renderer over already-gathered data; the FS
32
38
  // discovery lives in gatherPromptInput (mirrors the bash split: shell read the
33
39
  // files, the renderer just laid out bytes).
34
40
  // ─────────────────────────────────────────────────────────────────────────────
35
41
 
36
- /** One Layer-4 domain: the global + local halves of a single `<DOMAIN>.md` pair
37
- * (either may be absent). `domain` is the filename stem, used only for stable
38
- * ordering it is NOT emitted (the merge is organic, per the contract). */
42
+ /** One merged block: the global + local halves of a single stem (either may be
43
+ * absent), used for BOTH Layer 4 (`<DOMAIN>.md` at the .iapeer/ root) and Layer 5
44
+ * (`<STEM>.md` in .iapeer/fragments/). `domain` is the filename stem, used only
45
+ * for stable ordering — it is NOT emitted (the merge is organic). Within a block
46
+ * global precedes local (general → specific). */
39
47
  export interface PromptDomainBlock {
40
48
  domain: string
41
- /** ~/.iapeer/<DOMAIN>.md content (general). */
49
+ /** Global half content (general): ~/.iapeer/<DOMAIN>.md or ~/.iapeer/fragments/<STEM>.md. */
42
50
  global?: string
43
- /** <cwd>/.iapeer/<DOMAIN>.md content (specific — overrides global). */
51
+ /** Local half content (specific — overrides global): <cwd>/.iapeer/… counterpart. */
44
52
  local?: string
45
53
  }
46
54
 
@@ -66,6 +74,10 @@ export interface ComposePromptInput {
66
74
  /** Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root. Empty/
67
75
  * omitted → the layer emits nothing. */
68
76
  pluginDomains?: PromptDomainBlock[]
77
+ /** Layer 5: every `<STEM>.md` pair in the .iapeer/fragments/ subdir (global +
78
+ * local), primitive-owned and machine-regenerated. Empty/omitted → the layer
79
+ * emits nothing. Appended LAST (after Layer 4). */
80
+ promptFragments?: PromptDomainBlock[]
69
81
  }
70
82
 
71
83
  /**
@@ -87,10 +99,12 @@ export interface ComposePromptInput {
87
99
  * peerDoctrine
88
100
  * [\n\n + registry section — only when peers.length > 0]
89
101
  * [\n\n + merged domains — only when pluginDomains is non-empty]
102
+ * [\n\n + merged fragments — only when promptFragments is non-empty]
90
103
  *
91
- * Layers 1+2 are byte-for-byte the legacy jq output; layers 3+4 are appended
104
+ * Layers 1+2 are byte-for-byte the legacy jq output; layers 3+4+5 are appended
92
105
  * (each as a `\n\n`-separated section) ONLY when they have content, so a peer
93
- * with no registry and no extra domains produces the exact legacy bytes.
106
+ * with no registry, no extra domains and no fragments produces the exact legacy
107
+ * bytes.
94
108
  *
95
109
  * Each YAML value is a JSON string literal (jq @json: JSON.stringify), which is
96
110
  * also a valid YAML double-quoted scalar — safe against colons/quotes/newlines.
@@ -266,6 +280,17 @@ export interface LaunchConfig extends LaunchAdapterConfig {
266
280
  maxAgeSecs: number
267
281
  /** Log dir for pipe-pane output. */
268
282
  logDir: string
283
+ /**
284
+ * Durable EXIT-CAUSE log dir (~/.iapeer/logs/iapeer — next to lifecycle.log,
285
+ * where the investigator looks). When set, launch installs a tmux `pane-died`
286
+ * hook that records WHY the session's process died (exit status / signal) AT THE
287
+ * MOMENT of death, into `<exitLogDir>/exits.log` — the blind spot the daemon's
288
+ * 60 s supervise tick (reaped-gone) can only see post-factum, after the exit code
289
+ * is already lost. Routed through cfg (NOT re-resolved from env) so it is isolated
290
+ * by the same sandbox as the rest of launch; a FALSY dir → no hook installed and
291
+ * `remain-on-exit` stays off (original behavior — a partial/test cfg never writes
292
+ * and never lingers a dead pane). See exitCauseHook in index.ts. */
293
+ exitLogDir?: string
269
294
  env?: NodeJS.ProcessEnv
270
295
  /**
271
296
  * Always-on bring-up (infra runtimes held by launchd KeepAlive): SKIP the
@@ -805,6 +805,10 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
805
805
  readyGateSecs: cfg.readyGateSecs,
806
806
  maxAgeSecs: cfg.maxAgeSecs,
807
807
  logDir: cfg.logDir,
808
+ // Exit-cause log → next to lifecycle.log (~/.iapeer/logs/iapeer), where the
809
+ // investigator already looks: a self-death now leaves `exits.log` with the
810
+ // status/signal the daemon's post-factum reaped-gone could never recover.
811
+ exitLogDir: cfg.eventLogDir,
808
812
  env,
809
813
  }
810
814
  // C2 — initial_prompt (launch-seed): on a FRESH wake, seed the first turn with
@@ -469,7 +469,7 @@ export async function routeSend(
469
469
  const index = readPeersIndex()
470
470
  const peer = findPeer(index, personality)
471
471
  if (!peer) {
472
- return err(`peer "${personality}" is not in the IAPeer peers index; message NOT delivered`)
472
+ return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
473
473
  }
474
474
 
475
475
  // Built once — it is both the live-delivery payload and, on a miss, the wake