@agfpd/iapeer 0.2.33 → 0.2.35

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.
@@ -0,0 +1,218 @@
1
+ // Profile standard — the CODE-formalized schema of peer-profile.json, the source
2
+ // of truth being the contract zone "Идентичность пира — модель, профиль, реестр".
3
+ //
4
+ // Two classes of fields (contract §"структура — два класса полей, не плоская каша"):
5
+ // • CORE identity (foundation-owned, validated here): personality (system mirror,
6
+ // self-heal = normalize(basename(cwd))), default_runtime (contract field; legacy
7
+ // `runtime` accepted during the staged migration), runtimes[] (incl. the default,
8
+ // ORDER = preference, first = primary), description, intelligence ∈
9
+ // {artificial|natural|absent}. Optional lifecycle: initial_prompt, wake_policy.
10
+ // • OWNER sections (validated only as well-typed, NEVER restructured here): interfaces
11
+ // (telegram/capability passport), aliases (legacy persistent-peer → contract
12
+ // relocates to interfaces.telegram.aliases, telegram-runtime owns the migration),
13
+ // notifier (notifier-runtime), <plugin> sections — each plugin writes only its own
14
+ // field via atomic merge.
15
+ //
16
+ // peers-profiles.json is a regenerable self-heal PROJECTION of the local profiles —
17
+ // projectProfileToRecord builds the record the index SHOULD hold; verify compares the
18
+ // live index against it, reindex rewrites it. The index is never the source of truth.
19
+
20
+ import { basename } from 'path'
21
+ import { isRuntime, normalizeIntelligenceValue, normalizeNameCandidate } from '../core/constants.ts'
22
+ import {
23
+ clampDescription,
24
+ readPeersIndex,
25
+ updatePeersIndex,
26
+ type PeerRecord,
27
+ type PeersUpdateOptions,
28
+ } from '../registry/index.ts'
29
+ import { readPeerProfile } from './index.ts'
30
+
31
+ export type IssueSeverity = 'error' | 'warn'
32
+ export interface ProfileIssue {
33
+ severity: IssueSeverity
34
+ field: string
35
+ message: string
36
+ }
37
+
38
+ /**
39
+ * Validate a RAW peer-profile.json object (already JSON-parsed) against the standard.
40
+ * `cwd` is required for the personality self-heal check (the mirror must equal
41
+ * normalize(basename(cwd))). Returns every issue found; an `error` means the profile
42
+ * is non-conformant, a `warn` is a transition/owner note that self-heal or a later
43
+ * owner migration resolves (a profile with only warnings still PASSES — `isConformant`).
44
+ */
45
+ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue[] {
46
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
47
+ return [{ severity: 'error', field: '<root>', message: 'peer-profile.json must be a JSON object' }]
48
+ }
49
+ const obj = raw as Record<string, unknown>
50
+ const issues: ProfileIssue[] = []
51
+
52
+ // personality — system mirror, self-healed from the cwd (never a source). A stored
53
+ // value that disagrees is a WARN (self-heal rewrites it), not an error.
54
+ const expected = normalizeNameCandidate(basename(cwd))
55
+ if (typeof obj.personality !== 'string' || obj.personality !== expected) {
56
+ issues.push({
57
+ severity: 'warn',
58
+ field: 'personality',
59
+ message: `mirror "${String(obj.personality ?? '')}" ≠ normalize(basename(cwd)) "${expected}" — self-heal will rewrite`,
60
+ })
61
+ }
62
+
63
+ // default_runtime (contract) / runtime (legacy, staged migration).
64
+ const dr = obj.default_runtime ?? obj.runtime
65
+ if (!isRuntime(dr)) {
66
+ issues.push({ severity: 'error', field: 'default_runtime', message: `must be a valid runtime id, got "${String(dr ?? '')}"` })
67
+ } else if (obj.default_runtime === undefined) {
68
+ issues.push({ severity: 'warn', field: 'default_runtime', message: 'legacy field name `runtime` — standard is `default_runtime` (staged migration)' })
69
+ }
70
+
71
+ // runtimes[] — array of valid runtime ids that includes the default_runtime.
72
+ if (!Array.isArray(obj.runtimes)) {
73
+ issues.push({ severity: 'error', field: 'runtimes', message: 'must be an array of runtime ids' })
74
+ } else {
75
+ for (const r of obj.runtimes) {
76
+ if (!isRuntime(r)) issues.push({ severity: 'error', field: 'runtimes', message: `invalid runtime "${String(r)}"` })
77
+ }
78
+ if (isRuntime(dr) && !(obj.runtimes as unknown[]).includes(dr)) {
79
+ issues.push({ severity: 'error', field: 'runtimes', message: `must include the default_runtime "${dr}"` })
80
+ }
81
+ }
82
+
83
+ // description — string (may be empty).
84
+ if (obj.description !== undefined && typeof obj.description !== 'string') {
85
+ issues.push({ severity: 'error', field: 'description', message: 'must be a string' })
86
+ }
87
+
88
+ // intelligence — artificial|natural|absent (legacy human|scripted accepted on read).
89
+ if (obj.intelligence !== undefined && !normalizeIntelligenceValue(obj.intelligence)) {
90
+ issues.push({
91
+ severity: 'error',
92
+ field: 'intelligence',
93
+ message: `must be artificial|natural|absent (legacy human|scripted accepted), got "${String(obj.intelligence)}"`,
94
+ })
95
+ }
96
+
97
+ // Optional lifecycle fields (foundation-owned).
98
+ if (obj.initial_prompt !== undefined && typeof obj.initial_prompt !== 'string') {
99
+ issues.push({ severity: 'error', field: 'initial_prompt', message: 'must be a string' })
100
+ }
101
+ if (obj.wake_policy !== undefined && obj.wake_policy !== 'ephemeral') {
102
+ issues.push({ severity: 'warn', field: 'wake_policy', message: `unknown value "${String(obj.wake_policy)}" — only 'ephemeral' is honored` })
103
+ }
104
+
105
+ // Owner sections — well-typed only; foundation never restructures them.
106
+ if (obj.interfaces !== undefined && (typeof obj.interfaces !== 'object' || obj.interfaces === null || Array.isArray(obj.interfaces))) {
107
+ issues.push({ severity: 'error', field: 'interfaces', message: 'must be a JSON object' })
108
+ }
109
+ // Legacy top-level aliases — contract relocates to interfaces.telegram.aliases with
110
+ // the /alias-* namespace (clean slash-keys are reserved for the control layer).
111
+ // telegram-runtime owns that migration; flagged, not an error.
112
+ if (obj.aliases !== undefined) {
113
+ issues.push({
114
+ severity: 'warn',
115
+ field: 'aliases',
116
+ message: 'legacy top-level `aliases` — contract relocates to interfaces.telegram.aliases (/alias-* namespace); telegram-runtime migration pending',
117
+ })
118
+ }
119
+
120
+ return issues
121
+ }
122
+
123
+ /** A profile passes the standard when it has no `error`-severity issues. */
124
+ export function isConformant(issues: readonly ProfileIssue[]): boolean {
125
+ return !issues.some(i => i.severity === 'error')
126
+ }
127
+
128
+ /**
129
+ * The PeerRecord the index SHOULD hold for the peer at `cwd`, projected from its LOCAL
130
+ * profile (the self-heal source of truth) — readPeerProfile already self-heals the
131
+ * personality mirror and dual-reads default_runtime/runtime. null when the cwd has no
132
+ * profile. Used by verify (compare against the live index) and reindex (rewrite it).
133
+ */
134
+ export function projectProfileToRecord(cwd: string): PeerRecord | null {
135
+ const profile = readPeerProfile(cwd)
136
+ if (!profile) return null
137
+ return {
138
+ personality: profile.personality,
139
+ runtime: profile.runtime,
140
+ runtimes: profile.runtimes,
141
+ // The registry clamps the description to MAX_DESCRIPTION_LEN on read; project the
142
+ // SAME clamp so a long local description (timer/watcher run ~1000 chars) does not
143
+ // read back as a perpetual drift the index can never physically close.
144
+ description: clampDescription(profile.description).description,
145
+ intelligence: profile.intelligence,
146
+ cwd,
147
+ ...(profile.interfaces ? { interfaces: profile.interfaces } : {}),
148
+ }
149
+ }
150
+
151
+ /** The index↔local drift for one peer: the fields where the live registry record
152
+ * disagrees with the projection from the local profile (the self-heal source). Empty
153
+ * array = in sync. cwd is the registry pointer, never compared. */
154
+ export function recordDrift(indexRecord: PeerRecord, projected: PeerRecord): string[] {
155
+ const drift: string[] = []
156
+ if (indexRecord.personality !== projected.personality) drift.push('personality')
157
+ if (indexRecord.runtime !== projected.runtime) drift.push('default_runtime')
158
+ if (indexRecord.runtimes.join(',') !== projected.runtimes.join(',')) drift.push('runtimes')
159
+ if (indexRecord.description !== projected.description) drift.push('description')
160
+ if (indexRecord.intelligence !== projected.intelligence) drift.push('intelligence')
161
+ return drift
162
+ }
163
+
164
+ export interface ReconcileEntry {
165
+ personality: string
166
+ cwd: string
167
+ /** null when the local profile is absent (the registry points at a gone cwd). */
168
+ drift: string[] | null
169
+ }
170
+
171
+ /** READ-ONLY index↔local reconciliation: per registry entry, the drift vs its local
172
+ * profile projection (drift=null → local profile missing). The read-only half of the
173
+ * self-heal invariant — verify reports it, reindex repairs it. */
174
+ export function reconcileIndex(options: PeersUpdateOptions = {}): ReconcileEntry[] {
175
+ const index = readPeersIndex(options)
176
+ return index.peers.map(rec => {
177
+ const projected = projectProfileToRecord(rec.cwd)
178
+ return {
179
+ personality: rec.personality,
180
+ cwd: rec.cwd,
181
+ drift: projected ? recordDrift(rec, projected) : null,
182
+ }
183
+ })
184
+ }
185
+
186
+ /**
187
+ * Self-heal the index: REPLACE every registry record with the projection from its
188
+ * local profile (the source of truth). Unlike upsertPeer (which UNIONs runtimes so a
189
+ * boot never drops one), reindex is a deliberate REWRITE — it is how a local profile's
190
+ * narrowed runtimes (e.g. arthur dropped to [telegram]) propagate to the index. A peer
191
+ * whose local profile is missing is left untouched (and surfaced in `missing`). The
192
+ * legacy-safe intelligenceRaw is preserved when the nature is unchanged (never silently
193
+ * migrate the live vocab — the human→natural incident class).
194
+ */
195
+ export async function reindexFromLocals(
196
+ options: PeersUpdateOptions = {},
197
+ ): Promise<{ healed: string[]; missing: string[] }> {
198
+ const healed: string[] = []
199
+ const missing: string[] = []
200
+ await updatePeersIndex(index => {
201
+ const peers = index.peers.map(rec => {
202
+ const projected = projectProfileToRecord(rec.cwd)
203
+ if (!projected) {
204
+ missing.push(rec.personality)
205
+ return rec
206
+ }
207
+ const drift = recordDrift(rec, projected)
208
+ if (drift.length > 0) healed.push(`${rec.personality}: ${drift.join(', ')}`)
209
+ const intelligenceRaw =
210
+ rec.intelligenceRaw !== undefined && normalizeIntelligenceValue(rec.intelligenceRaw) === projected.intelligence
211
+ ? rec.intelligenceRaw
212
+ : undefined
213
+ return { ...projected, ...(intelligenceRaw !== undefined ? { intelligenceRaw } : {}) }
214
+ })
215
+ return { ...index, peers }
216
+ }, options)
217
+ return { healed, missing }
218
+ }
@@ -97,15 +97,28 @@ describe('composeSystemPrompt — layer 4 (other domains)', () => {
97
97
  expect(out).not.toMatch(/\n{4,}/) // no 3+ blank lines = no empty block injected
98
98
  })
99
99
 
100
- test('within a domain, global precedes local (general → specific)', () => {
100
+ test('within a domain, global precedes local (general → specific), each its own section', () => {
101
101
  const out = composeSystemPrompt({
102
102
  ...base,
103
103
  pluginDomains: [dom({ domain: 'MM', global: 'mm-global', local: 'mm-local' })],
104
104
  })
105
- expect(out).toContain('mm-global\nmm-local')
105
+ // per-file now: global and local are SEPARATE sections (blank line between),
106
+ // not a tight single-newline merge
107
+ expect(out).toContain('mm-global\n\nmm-local')
106
108
  expect(out.indexOf('mm-global')).toBeLessThan(out.indexOf('mm-local'))
107
109
  })
108
110
 
111
+ test('per-file path markers: each half carries its <!-- path --> marker', () => {
112
+ const out = composeSystemPrompt({
113
+ ...base,
114
+ pluginDomains: [
115
+ dom({ domain: 'MM', global: 'mm-global\n', globalPath: '~/.iapeer/MERGEMIND.md', local: 'mm-local\n', localPath: '~/p/.iapeer/MERGEMIND.md' }),
116
+ ],
117
+ })
118
+ expect(out).toContain('<!-- ~/.iapeer/MERGEMIND.md -->\nmm-global')
119
+ expect(out).toContain('<!-- ~/p/.iapeer/MERGEMIND.md -->\nmm-local')
120
+ })
121
+
109
122
  test('layer 4 sits after layer 3; domains kept in the order given', () => {
110
123
  const out = composeSystemPrompt({
111
124
  ...base,
@@ -156,7 +169,7 @@ describe('composeSystemPrompt — layer 5 (doctrine fragments)', () => {
156
169
  ...base,
157
170
  promptFragments: [frag({ domain: 'MEM', global: 'mem-guide', local: 'mem-index' })],
158
171
  })
159
- expect(out).toContain('mem-guide\nmem-index')
172
+ expect(out).toContain('mem-guide\n\nmem-index') // per-file: separate sections
160
173
  expect(out.indexOf('mem-guide')).toBeLessThan(out.indexOf('mem-index'))
161
174
  })
162
175
 
@@ -268,13 +281,19 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
268
281
  expect((input.pluginDomains ?? []).map(d => d.domain)).toEqual(['AAA', 'MARKER', 'MERGEMIND', 'ZZZ'])
269
282
  })
270
283
 
271
- test('layer 4: per-domain global/local halves resolved correctly', () => {
284
+ test('layer 4: per-domain global/local halves resolved correctly, each with its display path', () => {
272
285
  const input = gather()
273
286
  const byName = Object.fromEntries((input.pluginDomains ?? []).map(d => [d.domain, d]))
274
- expect(byName.MERGEMIND).toEqual({ domain: 'MERGEMIND', global: 'mm-global\n', local: 'mm-local\n' })
275
- expect(byName.AAA).toEqual({ domain: 'AAA', global: 'aaa-global-only\n' })
276
- expect(byName.ZZZ).toEqual({ domain: 'ZZZ', local: 'zzz-local-only\n' })
277
- expect(byName.MARKER).toEqual({ domain: 'MARKER', local: '' }) // present but empty
287
+ expect(byName.MERGEMIND).toEqual({
288
+ domain: 'MERGEMIND',
289
+ global: 'mm-global\n',
290
+ globalPath: join(globalRoot, 'MERGEMIND.md'),
291
+ local: 'mm-local\n',
292
+ localPath: join(localRoot, 'MERGEMIND.md'),
293
+ })
294
+ expect(byName.AAA).toEqual({ domain: 'AAA', global: 'aaa-global-only\n', globalPath: join(globalRoot, 'AAA.md') })
295
+ expect(byName.ZZZ).toEqual({ domain: 'ZZZ', local: 'zzz-local-only\n', localPath: join(localRoot, 'ZZZ.md') })
296
+ expect(byName.MARKER).toEqual({ domain: 'MARKER', local: '', localPath: join(localRoot, 'MARKER.md') }) // present but empty
278
297
  })
279
298
 
280
299
  test('layer 5: fragments/ subdir scanned, paired by stem (guide global + index local), sorted', () => {
@@ -282,8 +301,16 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
282
301
  const frags = input.promptFragments ?? []
283
302
  expect(frags.map(f => f.domain)).toEqual(['iapeer-memory', 'other-primitive'])
284
303
  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' })
304
+ const gfrag = join(globalRoot, 'fragments')
305
+ const lfrag = join(localRoot, 'fragments')
306
+ expect(byName['iapeer-memory']).toEqual({
307
+ domain: 'iapeer-memory',
308
+ global: 'mem-guide\n',
309
+ globalPath: join(gfrag, 'iapeer-memory.md'),
310
+ local: 'mem-index\n',
311
+ localPath: join(lfrag, 'iapeer-memory.md'),
312
+ })
313
+ expect(byName['other-primitive']).toEqual({ domain: 'other-primitive', global: 'other-frag\n', globalPath: join(gfrag, 'other-primitive.md') })
287
314
  // the non-.md notes.txt never became a fragment
288
315
  expect(frags.map(f => f.domain)).not.toContain('notes')
289
316
  })
@@ -320,12 +347,15 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
320
347
  expect(prompt).toContain('## Known peers')
321
348
  expect(prompt).toContain('- arthur — owner')
322
349
  expect(prompt).toContain('- boris — PM')
323
- // Layer 4 (markers excluded, others merged, sorted)
350
+ // Layer 4 (presence-marker file excluded, others per-file with path markers, sorted)
324
351
  expect(prompt).toContain('aaa-global-only')
325
- expect(prompt).toContain('mm-global\nmm-local')
326
352
  expect(prompt).toContain('zzz-local-only')
327
- // Layer 5 (fragments: guide+index paired, global-only fragment)
328
- expect(prompt).toContain('mem-guide\nmem-index')
353
+ // global half then a fresh marker then the local half (each its own marked section)
354
+ expect(prompt).toContain(`<!-- ${join(globalRoot, 'MERGEMIND.md')} -->\nmm-global`)
355
+ expect(prompt).toContain(`mm-global\n\n<!-- ${join(localRoot, 'MERGEMIND.md')} -->\nmm-local`)
356
+ // Layer 5 (fragments: guide+index paired as separate marked sections, global-only fragment)
357
+ expect(prompt).toContain(`<!-- ${join(globalRoot, 'fragments', 'iapeer-memory.md')} -->\nmem-guide`)
358
+ expect(prompt).toContain(`mem-guide\n\n<!-- ${join(localRoot, 'fragments', 'iapeer-memory.md')} -->\nmem-index`)
329
359
  expect(prompt).toContain('other-frag')
330
360
 
331
361
  const order = [
@@ -357,10 +387,13 @@ describe('gatherPromptInput — FS discovery of all 4 layers', () => {
357
387
  expect(input.pluginDomains).toEqual([])
358
388
  expect(input.promptFragments).toEqual([]) // no fragments/ subdir → empty layer
359
389
  expect(input.peers).toEqual([])
360
- // composes to just the YAML block (+ empty doctrine), no extra layers
390
+ // composes to just the YAML block (no doctrine, no layers); the per-file
391
+ // assembly normalizes the single section's trailing newlines to exactly one.
361
392
  const prompt = composeSystemPrompt(input)
362
393
  expect(prompt).not.toContain('## Known peers')
363
- expect(prompt.endsWith('---\n\n')).toBe(true)
394
+ expect(prompt).not.toContain('<!--') // no files → no markers
395
+ expect(prompt.endsWith('---\n')).toBe(true)
396
+ expect(prompt.endsWith('---\n\n')).toBe(false)
364
397
  })
365
398
 
366
399
  test('doctrine excluded from Layer 4 case-INSENSITIVELY; .MD ext picked up (verify edge-lens fix)', () => {
@@ -1,8 +1,8 @@
1
- // Golden-equivalence test: composeSystemPrompt MUST produce byte-for-byte the
2
- // same output as the real jq doctrine-merge in claude-start.sh:264-290. Each
3
- // case runs the ACTUAL jq pipeline (extracted verbatim) and the TS function,
4
- // then byte-compares. This is the durable form of the Ф3 adversarial-verify
5
- // (which caught a 1-byte divergence on the empty-global-file case).
1
+ // composeSystemPrompt — the YAML identity block (Layer 1) stays byte-for-byte the
2
+ // claude-start.sh:264-290 jq layout (the durable golden); the file-sourced layers
3
+ // (2/4/5) are now rendered PER FILE, each prefixed by a `<!-- <path> -->` marker.
4
+ // This test pins BOTH: the jq-equivalent YAML prefix, and the marker format /
5
+ // empty-file-drops rules of the per-file assembly.
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
8
8
  import { spawnSync } from 'child_process'
@@ -15,7 +15,7 @@ import type { ComposePromptInput } from './types.ts'
15
15
  let dir: string
16
16
  let jqProg: string
17
17
 
18
- // The jq program verbatim from claude-start.sh:274-284.
18
+ // The jq program verbatim from claude-start.sh:274-284 — the YAML identity block.
19
19
  const JQ_PROGRAM = `"---",
20
20
  "personality: \\($personality | @json)",
21
21
  "description: \\($description | @json)",
@@ -39,16 +39,9 @@ beforeAll(() => {
39
39
  })
40
40
  afterAll(() => rmSync(dir, { recursive: true, force: true }))
41
41
 
42
- /** Run the exact bash/jq pipeline; `globalDoctrine` undefined = file absent. */
43
- function bashReference(input: ComposePromptInput): string {
44
- const peerPath = join(dir, 'peer.md')
45
- writeFileSync(peerPath, input.peerDoctrine)
46
- let globalBlock = ''
47
- if (input.globalDoctrine !== undefined) {
48
- const gp = join(dir, 'global.md')
49
- writeFileSync(gp, input.globalDoctrine)
50
- globalBlock = `if [ -f ${shq(gp)} ]; then cat ${shq(gp)}; printf "\\n"; fi`
51
- }
42
+ /** The jq YAML identity block alone (no doctrine appended) — the byte-for-byte
43
+ * Layer-1 golden every composed prompt must START with. */
44
+ function jqYamlBlock(input: ComposePromptInput): string {
52
45
  const script = `set -e
53
46
  jq -n --raw-output \
54
47
  --arg personality ${shq(input.personality)} \
@@ -59,9 +52,7 @@ jq -n --raw-output \
59
52
  --arg user ${shq(input.user)} \
60
53
  --arg hostname ${shq(input.hostname)} \
61
54
  --arg today ${shq(input.today)} \
62
- -f ${shq(jqProg)}
63
- ${globalBlock}
64
- cat ${shq(peerPath)}`
55
+ -f ${shq(jqProg)}`
65
56
  const r = spawnSync('bash', ['-c', script], { encoding: 'utf8' })
66
57
  if (r.status !== 0) throw new Error(`bash reference failed: ${r.stderr}`)
67
58
  return r.stdout
@@ -77,22 +68,78 @@ const base = {
77
68
  today: '2026-06-06',
78
69
  }
79
70
 
80
- const cases: Array<{ name: string; input: ComposePromptInput }> = [
81
- { name: 'basic, no global', input: { ...base, description: 'owner', peerDoctrine: 'Be helpful.\n' } },
82
- { name: 'present non-empty global', input: { ...base, description: 'owner', peerDoctrine: 'peer\n', globalDoctrine: 'GLOBAL\n' } },
83
- { name: 'EMPTY-but-present global (the 1-byte divergence the verify caught)', input: { ...base, description: 'owner', peerDoctrine: 'peer', globalDoctrine: '' } },
84
- { name: 'hostile description: colon + quote + newline', input: { ...base, description: 'a: "b"\nc', peerDoctrine: 'doc\n' } },
85
- { name: 'unicode + emoji description', input: { ...base, description: 'Артур 🦊 №2', peerDoctrine: 'док\n', globalDoctrine: 'гл\n' } },
86
- { name: 'multiline peer doctrine, no trailing newline', input: { ...base, description: 'd', peerDoctrine: 'line1\nline2' } },
87
- { name: 'description with backslash and slash', input: { ...base, description: 'a\\b/c', peerDoctrine: 'x\n' } },
88
- ]
89
-
90
- describe('composeSystemPrompt golden-equivalence vs claude-start.sh jq', () => {
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+ // Layer 1 — the YAML identity block is byte-for-byte the jq layout
73
+ // ─────────────────────────────────────────────────────────────────────────────
74
+
75
+ describe('composeSystemPrompt — Layer 1 YAML block is byte-for-byte the jq layout', () => {
76
+ const cases: Array<{ name: string; input: ComposePromptInput }> = [
77
+ { name: 'basic', input: { ...base, description: 'owner', peerDoctrine: 'Be helpful.\n' } },
78
+ { name: 'hostile description: colon + quote + newline', input: { ...base, description: 'a: "b"\nc', peerDoctrine: 'doc\n' } },
79
+ { name: 'unicode + emoji description', input: { ...base, description: 'Артур 🦊 №2', peerDoctrine: 'док\n' } },
80
+ { name: 'description with backslash and slash', input: { ...base, description: 'a\\b/c', peerDoctrine: 'x\n' } },
81
+ { name: 'empty description', input: { ...base, description: '', peerDoctrine: 'x\n' } },
82
+ ]
91
83
  for (const c of cases) {
92
84
  test(c.name, () => {
93
- const ref = bashReference(c.input)
94
- const got = composeSystemPrompt(c.input)
95
- expect(got).toBe(ref)
85
+ const yaml = jqYamlBlock(c.input)
86
+ // the composed prompt opens with the exact jq YAML block (the `---…---\n\n`)
87
+ expect(composeSystemPrompt(c.input).startsWith(yaml)).toBe(true)
96
88
  })
97
89
  }
98
90
  })
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+ // Layer 2 — IAPEER.md doctrine is rendered per-file with a path marker
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+
96
+ describe('composeSystemPrompt — per-file path markers (Layer 2 doctrine)', () => {
97
+ const input: ComposePromptInput = {
98
+ ...base,
99
+ description: 'owner',
100
+ peerDoctrine: 'Be the implementer.\n',
101
+ peerDoctrinePath: '~/Peers/arthur/.iapeer/IAPEER.md',
102
+ globalDoctrine: 'Host rules.\n',
103
+ globalDoctrinePath: '~/.iapeer/IAPEER.md',
104
+ }
105
+
106
+ test('each doctrine file is prefixed by its <!-- path --> marker, global before local', () => {
107
+ const out = composeSystemPrompt(input)
108
+ expect(out).toContain('<!-- ~/.iapeer/IAPEER.md -->\nHost rules.')
109
+ expect(out).toContain('<!-- ~/Peers/arthur/.iapeer/IAPEER.md -->\nBe the implementer.')
110
+ expect(out.indexOf('<!-- ~/.iapeer/IAPEER.md -->')).toBeLessThan(
111
+ out.indexOf('<!-- ~/Peers/arthur/.iapeer/IAPEER.md -->'),
112
+ )
113
+ })
114
+
115
+ test('marker sits AFTER the YAML block (one blank line between)', () => {
116
+ const out = composeSystemPrompt(input)
117
+ expect(out).toContain('---\n\n<!-- ~/.iapeer/IAPEER.md -->')
118
+ })
119
+
120
+ test('a path-less content (pure-renderer caller) renders WITHOUT a marker', () => {
121
+ const out = composeSystemPrompt({ ...base, description: 'd', peerDoctrine: 'no marker here\n' })
122
+ expect(out).toContain('no marker here')
123
+ expect(out).not.toContain('<!--')
124
+ })
125
+
126
+ test('an EMPTY doctrine file contributes no section and no dangling marker', () => {
127
+ const out = composeSystemPrompt({
128
+ ...base,
129
+ description: 'd',
130
+ peerDoctrine: 'real\n',
131
+ peerDoctrinePath: '~/p/IAPEER.md',
132
+ globalDoctrine: '', // present but empty → dropped
133
+ globalDoctrinePath: '~/.iapeer/IAPEER.md',
134
+ })
135
+ expect(out).not.toContain('~/.iapeer/IAPEER.md') // empty global marker absent
136
+ expect(out).toContain('<!-- ~/p/IAPEER.md -->\nreal')
137
+ })
138
+
139
+ test('output ends with exactly one newline; no 3+ blank-line gaps', () => {
140
+ const out = composeSystemPrompt(input)
141
+ expect(out.endsWith('\n')).toBe(true)
142
+ expect(out.endsWith('\n\n')).toBe(false)
143
+ expect(out).not.toMatch(/\n{3,}/)
144
+ })
145
+ })