@agfpd/iapeer 0.2.7 → 0.2.9

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.7",
4
- "description": "Foundation core for the IAPeer multi-agent ecosystem: identity, registry, storage, codec.",
3
+ "version": "0.2.9",
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
  }
@@ -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.
@@ -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
@@ -6,9 +6,10 @@
6
6
  // Flow:
7
7
  // 1. latest = `npm view @agfpd/iapeer version` (the cloud's truth)
8
8
  // 2. installed == latest && !--force → "already latest" (no needless rebuild/restart)
9
- // 3. `npx @agfpd/iapeer@<latest> install` (fetch + rebuild ~/.local/bin/iapeer
10
- // atomically; the COMPILED binary can't rebuild itself from source, so we shell to
11
- // npx, which runs the freshly-fetched package's own install same path consumers use)
9
+ // 3. fetch the published tarball + build from its SOURCE (defaultRunInstall) — the
10
+ // COMPILED binary can't rebuild itself, so we pull the freshly-published package
11
+ // and run ITS own source installer. DELIBERATELY NOT `npx install` (see
12
+ // defaultRunInstall for why npx is unsafe here).
12
13
  // 4. kickstart com.agfpd.iapeer IF loaded (activate the new binary)
13
14
  //
14
15
  // Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon). It never
@@ -20,7 +21,10 @@
20
21
  // unit-testable with no network and no launchctl; the defaults are the real impls.
21
22
 
22
23
  import { spawnSync } from 'child_process'
24
+ import { mkdtempSync, readdirSync, rmSync } from 'fs'
23
25
  import { connect } from 'net'
26
+ import { tmpdir } from 'os'
27
+ import { join } from 'path'
24
28
  import { IapError } from '../core/errors.ts'
25
29
  import { IAPEER_VERSION } from '../core/version.ts'
26
30
  import { kickstartDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
@@ -144,14 +148,51 @@ function defaultResolveVersion(spec: string, env: NodeJS.ProcessEnv): string | n
144
148
  return SEMVER_RE.test(v) ? v : null
145
149
  }
146
150
 
147
- /** Default installer: `npx -y @agfpd/iapeer@<version> install` (pull from cloud + rebuild). */
151
+ /**
152
+ * Default installer — fetch the published tarball and build from its SOURCE,
153
+ * DELIBERATELY bypassing `npx`. Pull from the cloud + rebuild ~/.local/bin/iapeer.
154
+ *
155
+ * Why not `npx -y @agfpd/iapeer@<v> install`: the package's bin is named `iapeer`,
156
+ * and once `~/.local/bin/iapeer` is on PATH (true on every host AFTER the first
157
+ * install) npx resolves that bin NAME to the COMPILED binary already on PATH and
158
+ * runs ITS `install` — which cannot rebuild itself from source (`bun build --compile`
159
+ * gets a `/$bunfs/root` entrypoint → FileNotFound) — instead of fetching + running the
160
+ * freshly-published source. Verified reproducible (09.06, 0.2.8 deploy): with NO
161
+ * `iapeer` on PATH the same npx invocation prints `command not found` — it never
162
+ * installs the package — so this is a structural bin-name collision, NOT the
163
+ * publish-propagation transient (waiting/retry does not cure it).
164
+ *
165
+ * Deterministic path instead — no npx command-resolution in the loop:
166
+ * 1. `npm pack <pkg>@<v>` → the published tarball (rooted at `package/`).
167
+ * 2. `tar xzf` → extract.
168
+ * 3. `npm install --omit=dev` in the extracted dir — the tarball ships only
169
+ * src/bin (no node_modules), and the source build imports prod deps
170
+ * (@modelcontextprotocol/sdk, …).
171
+ * 4. run the package's OWN bin shim `bash <pkg>/bin/iapeer install` — that is
172
+ * `bun src/cli/index.ts install` from the REAL fetched source → builds the prod
173
+ * binary atomically (keeps `.prev`).
174
+ * Needs npm + tar + bash + bun on PATH (the toolchain the bootstrap already assumes).
175
+ */
148
176
  function defaultRunInstall(version: string, env: NodeJS.ProcessEnv): boolean {
149
177
  if (env.IAPEER_TEST_SANDBOX === '1') {
150
- // A real npx install rebuilds the prod ~/.local/bin/iapeer — never under a test.
151
- throw new IapError('refusing a real `npx install` under IAPEER_TEST_SANDBOX=1 — inject runInstall in tests')
178
+ // A real install rebuilds the prod ~/.local/bin/iapeer — never under a test.
179
+ throw new IapError('refusing a real install under IAPEER_TEST_SANDBOX=1 — inject runInstall in tests')
180
+ }
181
+ const tmp = mkdtempSync(join(tmpdir(), 'iapeer-deploy-'))
182
+ try {
183
+ const pack = spawnSync('npm', ['pack', '--silent', '--pack-destination', tmp, `${IAPEER_PACKAGE}@${version}`], { encoding: 'utf8', env })
184
+ if (pack.status !== 0) return false
185
+ const tgz = readdirSync(tmp).find(f => f.endsWith('.tgz'))
186
+ if (!tgz) return false
187
+ if (spawnSync('tar', ['xzf', join(tmp, tgz), '-C', tmp], { env }).status !== 0) return false
188
+ const pkg = join(tmp, 'package') // npm-pack tarballs always root at `package/`
189
+ const deps = spawnSync('npm', ['install', '--omit=dev', '--no-audit', '--no-fund', '--silent'], { cwd: pkg, stdio: 'inherit', env })
190
+ if (deps.status !== 0) return false
191
+ const build = spawnSync('bash', [join(pkg, 'bin', 'iapeer'), 'install'], { stdio: 'inherit', env })
192
+ return build.status === 0
193
+ } finally {
194
+ rmSync(tmp, { recursive: true, force: true })
152
195
  }
153
- const r = spawnSync('npx', ['-y', `${IAPEER_PACKAGE}@${version}`, 'install'], { stdio: 'inherit', env })
154
- return r.status === 0
155
196
  }
156
197
 
157
198
  /**
@@ -137,9 +137,9 @@ describe('updateIapeer — failure paths', () => {
137
137
  })
138
138
 
139
139
  describe('updateIapeer — real-installer sandbox guard', () => {
140
- test('default runInstall refuses a real npx install under IAPEER_TEST_SANDBOX', () => {
140
+ test('default runInstall refuses a real install under IAPEER_TEST_SANDBOX', () => {
141
141
  // fetchLatest injected (newer) so the gate proceeds to the DEFAULT installer,
142
- // which must refuse rather than npx-install over the prod ~/.local/bin/iapeer.
142
+ // which must refuse rather than fetch+build over the prod ~/.local/bin/iapeer.
143
143
  expect(() =>
144
144
  updateIapeer({
145
145
  env: { IAPEER_TEST_SANDBOX: '1' },