@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.
- package/package.json +1 -1
- package/src/cli/cli.test.ts +23 -0
- package/src/cli/index.ts +85 -6
- package/src/daemon/index.ts +12 -1
- package/src/daemon/main.test.ts +63 -0
- package/src/daemon/main.ts +36 -0
- package/src/enable/enable.test.ts +15 -12
- package/src/enable/index.ts +21 -13
- package/src/identity/index.ts +9 -1
- package/src/identity/profileStandard.test.ts +116 -0
- package/src/identity/profileStandard.ts +218 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +49 -16
- package/src/launch/composeSystemPrompt.test.ts +80 -33
- package/src/launch/composeSystemPrompt.ts +99 -61
- package/src/launch/types.ts +13 -2
- package/src/lifecycle/index.ts +9 -3
- package/src/registry/index.ts +9 -6
- package/src/transport/index.ts +25 -0
- package/src/transport/routesend-wake.test.ts +69 -0
|
@@ -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
|
-
|
|
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({
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 (
|
|
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
|
-
//
|
|
328
|
-
expect(prompt).toContain('
|
|
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 (
|
|
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.
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
expect(
|
|
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
|
+
})
|