@agfpd/iapeer 0.2.33 → 0.2.34
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/index.ts +63 -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/registry/index.ts +9 -6
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -45,6 +45,13 @@ import {
|
|
|
45
45
|
import { getAdapter } from '../launch/index.ts'
|
|
46
46
|
import { isFoundationOwnedPlist, kickstartDaemon, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
|
|
47
47
|
import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
|
|
48
|
+
import { peerProfilePath } from '../storage/index.ts'
|
|
49
|
+
import {
|
|
50
|
+
isConformant,
|
|
51
|
+
reconcileIndex,
|
|
52
|
+
reindexFromLocals,
|
|
53
|
+
validateProfileStandard,
|
|
54
|
+
} from '../identity/profileStandard.ts'
|
|
48
55
|
import { runAlwaysOn } from '../launch/launchdRun.ts'
|
|
49
56
|
import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
|
|
50
57
|
import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
|
|
@@ -544,6 +551,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
544
551
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
545
552
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
546
553
|
list [--json] registered peers + per-runtime liveness
|
|
554
|
+
verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index)
|
|
547
555
|
stop <peer> [runtime] | --all durable-stop a warm peer / bootout an always-on one
|
|
548
556
|
start <peer> [runtime] re-enable a stopped peer / bootstrap an always-on one
|
|
549
557
|
remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
|
|
@@ -871,6 +879,61 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
871
879
|
out(flags.json ? JSON.stringify(rows, null, 2) + '\n' : formatListTable(rows))
|
|
872
880
|
return 0
|
|
873
881
|
}
|
|
882
|
+
case 'verify': {
|
|
883
|
+
// Profile conformance + index↔local self-heal reconciliation. Read-only by
|
|
884
|
+
// default; --fix self-heals the index (reindex from local profiles). A drift
|
|
885
|
+
// is a signal that the index self-heal lapsed — caught by construction here.
|
|
886
|
+
const index = readPeersIndex({ env })
|
|
887
|
+
let errors = 0
|
|
888
|
+
let driftCount = 0
|
|
889
|
+
const lines: string[] = []
|
|
890
|
+
for (const peer of index.peers) {
|
|
891
|
+
const path = peerProfilePath(peer.cwd)
|
|
892
|
+
if (!existsSync(path)) {
|
|
893
|
+
errors++
|
|
894
|
+
lines.push(`✗ ${peer.personality}: no local profile at ${path}`)
|
|
895
|
+
continue
|
|
896
|
+
}
|
|
897
|
+
let raw: unknown
|
|
898
|
+
try {
|
|
899
|
+
raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
900
|
+
} catch (e) {
|
|
901
|
+
errors++
|
|
902
|
+
lines.push(`✗ ${peer.personality}: profile is invalid JSON — ${e instanceof Error ? e.message : String(e)}`)
|
|
903
|
+
continue
|
|
904
|
+
}
|
|
905
|
+
const issues = validateProfileStandard(raw, peer.cwd)
|
|
906
|
+
const errs = issues.filter(i => i.severity === 'error')
|
|
907
|
+
const warns = issues.filter(i => i.severity === 'warn')
|
|
908
|
+
if (errs.length > 0) errors += errs.length
|
|
909
|
+
if (!isConformant(issues)) {
|
|
910
|
+
lines.push(`✗ ${peer.personality}: ${errs.map(i => `${i.field} — ${i.message}`).join('; ')}`)
|
|
911
|
+
} else if (warns.length > 0 && flags.json !== true) {
|
|
912
|
+
lines.push(`⚠ ${peer.personality}: ${warns.map(i => i.field).join(', ')}`)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const reconcile = reconcileIndex({ env })
|
|
916
|
+
for (const r of reconcile) {
|
|
917
|
+
if (r.drift === null) {
|
|
918
|
+
// missing local profile already reported above as an error
|
|
919
|
+
} else if (r.drift.length > 0) {
|
|
920
|
+
driftCount++
|
|
921
|
+
lines.push(`↯ ${r.personality}: index↔local drift on ${r.drift.join(', ')}`)
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (flags.json === true) {
|
|
925
|
+
out(JSON.stringify({ peers: index.peers.length, errors, drift: driftCount, reconcile }, null, 2) + '\n')
|
|
926
|
+
} else {
|
|
927
|
+
for (const l of lines) out(l + '\n')
|
|
928
|
+
out(`\n${index.peers.length} peers · ${errors} error(s) · ${driftCount} index↔local drift(s)\n`)
|
|
929
|
+
}
|
|
930
|
+
if (flags.fix === true && (driftCount > 0 || errors === 0)) {
|
|
931
|
+
const { healed, missing } = await reindexFromLocals({ env })
|
|
932
|
+
if (healed.length > 0) out(`self-healed index from local profiles:\n ${healed.join('\n ')}\n`)
|
|
933
|
+
if (missing.length > 0) errOut(`peers with no local profile (left untouched): ${missing.join(', ')}\n`)
|
|
934
|
+
}
|
|
935
|
+
return errors > 0 || (driftCount > 0 && flags.fix !== true) ? 1 : 0
|
|
936
|
+
}
|
|
874
937
|
case 'stop': {
|
|
875
938
|
// --all stops every registered peer (the fleet guard still refuses foreign
|
|
876
939
|
// persistent-peer plists, so the live fleet stays untouched).
|
|
@@ -44,21 +44,24 @@ describe('findPeerScopedEntry (per-peer, fleet-safe)', () => {
|
|
|
44
44
|
})
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
-
// the real `codex plugin list`
|
|
48
|
-
const CODEX_LIST =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
peer
|
|
53
|
-
|
|
47
|
+
// the real `codex plugin list --json` shape (codex-cli 0.138, live-captured fields)
|
|
48
|
+
const CODEX_LIST = JSON.stringify({
|
|
49
|
+
installed: [
|
|
50
|
+
{ pluginId: 'peer-voice@agfpd', installed: true, enabled: true },
|
|
51
|
+
{ pluginId: 'MergeMind@agfpd', installed: true, enabled: false },
|
|
52
|
+
{ pluginId: 'spawned-peer@agfpd', installed: false, enabled: false }, // marketplace-known, not installed
|
|
53
|
+
],
|
|
54
|
+
})
|
|
54
55
|
|
|
55
56
|
describe('parseCodexPluginStatus', () => {
|
|
56
|
-
test('reads the
|
|
57
|
+
test('reads the installed/enabled flags per plugin id', () => {
|
|
57
58
|
expect(parseCodexPluginStatus(CODEX_LIST, 'peer-voice@agfpd')).toBe('enabled')
|
|
58
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'spawned-peer@agfpd')).toBe('absent') //
|
|
59
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled')
|
|
60
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in
|
|
61
|
-
expect(parseCodexPluginStatus('', 'x@agfpd')).toBe('absent')
|
|
59
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'spawned-peer@agfpd')).toBe('absent') // installed:false
|
|
60
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled') // installed, not enabled
|
|
61
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in array
|
|
62
|
+
expect(parseCodexPluginStatus('', 'x@agfpd')).toBe('absent') // empty
|
|
63
|
+
expect(parseCodexPluginStatus('not json', 'x@agfpd')).toBe('absent') // malformed → fail-safe
|
|
64
|
+
expect(parseCodexPluginStatus('{"no":"installed-array"}', 'x@agfpd')).toBe('absent')
|
|
62
65
|
})
|
|
63
66
|
})
|
|
64
67
|
|
package/src/enable/index.ts
CHANGED
|
@@ -274,24 +274,32 @@ function enableClaude(plugin: string, marketplace: string, peerCwd: string, env:
|
|
|
274
274
|
return { runtime: 'claude', state: existing ? 'enabled' : 'installed', installPath: after.installPath }
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
/** Parse
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
277
|
+
/** Parse `codex plugin list --json` for one plugin id's status. Shape (codex-cli
|
|
278
|
+
* 0.138, live-verified): `{ installed: [{ pluginId, installed, enabled }, …] }`.
|
|
279
|
+
* A plugin absent from the array OR with `installed:false` → 'absent'; `enabled:true`
|
|
280
|
+
* → 'enabled'; installed-but-not-enabled → 'disabled'. Replaces the prior whitespace-
|
|
281
|
+
* table parse (brittle to column re-alignment / status-string wording) — the codex
|
|
282
|
+
* counterpart of the claude path's `parseInstalledPlugins`. Malformed/empty input →
|
|
283
|
+
* 'absent' (same fail-safe). PURE → unit-testable. */
|
|
284
|
+
export function parseCodexPluginStatus(jsonOutput: string, id: string): 'enabled' | 'disabled' | 'absent' {
|
|
285
|
+
let parsed: unknown
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(jsonOutput)
|
|
288
|
+
} catch {
|
|
288
289
|
return 'absent'
|
|
289
290
|
}
|
|
290
|
-
|
|
291
|
+
const installed = (parsed as { installed?: unknown } | null)?.installed
|
|
292
|
+
if (!Array.isArray(installed)) return 'absent'
|
|
293
|
+
const entry = installed.find(
|
|
294
|
+
(e): e is { pluginId: string; installed?: boolean; enabled?: boolean } =>
|
|
295
|
+
!!e && typeof e === 'object' && (e as { pluginId?: unknown }).pluginId === id,
|
|
296
|
+
)
|
|
297
|
+
if (!entry || entry.installed === false) return 'absent'
|
|
298
|
+
return entry.enabled === true ? 'enabled' : 'disabled'
|
|
291
299
|
}
|
|
292
300
|
|
|
293
301
|
function codexState(bin: string, id: string, env: NodeJS.ProcessEnv): 'enabled' | 'disabled' | 'absent' {
|
|
294
|
-
const r = spawnSync(bin, ['plugin', 'list'], { encoding: 'utf8', env: env as Record<string, string>, maxBuffer: 64 * 1024 * 1024 })
|
|
302
|
+
const r = spawnSync(bin, ['plugin', 'list', '--json'], { encoding: 'utf8', env: env as Record<string, string>, maxBuffer: 64 * 1024 * 1024 })
|
|
295
303
|
return parseCodexPluginStatus(r.stdout ?? '', id)
|
|
296
304
|
}
|
|
297
305
|
|
package/src/identity/index.ts
CHANGED
|
@@ -206,8 +206,16 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
|
|
|
206
206
|
if (typeof obj.personality !== 'string' || !obj.personality.trim()) {
|
|
207
207
|
throw new IapError(`${IAPEER_DIR}/${PEER_PROFILE_FILE} personality is required`)
|
|
208
208
|
}
|
|
209
|
+
// personality is a SYSTEM MIRROR (contract Идентичность): the canonical value is
|
|
210
|
+
// normalize(basename(cwd)). The stored field is read+normalized here; a mismatch
|
|
211
|
+
// with the cwd-derived name is SURFACED by validateProfileStandard (a self-heal WARN)
|
|
212
|
+
// rather than silently overridden on read — the read-time override conflicts with
|
|
213
|
+
// the `iapeer init --personality` override (a deliberately-named peer in a folder
|
|
214
|
+
// whose basename differs), so the heal stays a flagged drift, not a silent rewrite.
|
|
209
215
|
const personality = validatePersonality(obj.personality, 'peer-profile personality')
|
|
210
|
-
|
|
216
|
+
// default_runtime is the contract field name (the routing/wake/first-launch default);
|
|
217
|
+
// `runtime` is the legacy field. Dual-read accepts both during the staged migration.
|
|
218
|
+
const runtime = validateRuntime(obj.default_runtime ?? obj.runtime, 'peer-profile default_runtime')
|
|
211
219
|
const interfaces = normalizeInterfaces(obj.interfaces, `${IAPEER_DIR}/${PEER_PROFILE_FILE}`)
|
|
212
220
|
const intelligence = normalizeIntelligence(obj.intelligence, runtime)
|
|
213
221
|
return {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Profile-standard validator + index↔local self-heal (reconcile / reindex).
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
5
|
+
import { tmpdir } from 'os'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
import { isConformant, reconcileIndex, reindexFromLocals, validateProfileStandard } from './profileStandard.ts'
|
|
8
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
9
|
+
|
|
10
|
+
describe('validateProfileStandard', () => {
|
|
11
|
+
const cwd = '/Users/x/Peers/boris' // basename normalizes to "boris"
|
|
12
|
+
|
|
13
|
+
test('a conformant profile (contract field default_runtime) has no issues', () => {
|
|
14
|
+
const issues = validateProfileStandard(
|
|
15
|
+
{ personality: 'boris', default_runtime: 'claude', runtimes: ['claude'], description: 'PM', intelligence: 'artificial' },
|
|
16
|
+
cwd,
|
|
17
|
+
)
|
|
18
|
+
expect(issues).toEqual([])
|
|
19
|
+
expect(isConformant(issues)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('legacy `runtime` field → WARN (not error), still conformant', () => {
|
|
23
|
+
const issues = validateProfileStandard(
|
|
24
|
+
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial' },
|
|
25
|
+
cwd,
|
|
26
|
+
)
|
|
27
|
+
expect(isConformant(issues)).toBe(true)
|
|
28
|
+
expect(issues.find(i => i.field === 'default_runtime')?.severity).toBe('warn')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('top-level aliases → WARN (owner migration pending), still conformant', () => {
|
|
32
|
+
const issues = validateProfileStandard(
|
|
33
|
+
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', aliases: { '/new': 'x' } },
|
|
34
|
+
cwd,
|
|
35
|
+
)
|
|
36
|
+
expect(isConformant(issues)).toBe(true)
|
|
37
|
+
expect(issues.some(i => i.field === 'aliases' && i.severity === 'warn')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('personality mirror disagreeing with cwd → WARN (self-heal), not error', () => {
|
|
41
|
+
const issues = validateProfileStandard(
|
|
42
|
+
{ personality: 'WRONG', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial' },
|
|
43
|
+
cwd,
|
|
44
|
+
)
|
|
45
|
+
expect(isConformant(issues)).toBe(true)
|
|
46
|
+
expect(issues.some(i => i.field === 'personality' && i.severity === 'warn')).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('ERRORS: bad runtime, runtimes missing the default, bad intelligence', () => {
|
|
50
|
+
// runtime ids carry NO hyphen (/^[a-z][a-z0-9]{0,31}$/) → a hyphenated value is an error
|
|
51
|
+
expect(isConformant(validateProfileStandard({ default_runtime: 'no-dash', runtimes: ['claude'], intelligence: 'artificial' }, cwd))).toBe(false)
|
|
52
|
+
const e1 = validateProfileStandard({ default_runtime: 'claude', runtimes: ['codex'], intelligence: 'artificial' }, cwd)
|
|
53
|
+
expect(isConformant(e1)).toBe(false) // runtimes does not include the default
|
|
54
|
+
const e2 = validateProfileStandard({ default_runtime: 'claude', runtimes: ['claude'], intelligence: 'bogus' }, cwd)
|
|
55
|
+
expect(isConformant(e2)).toBe(false)
|
|
56
|
+
const e3 = validateProfileStandard({ runtimes: ['claude'], intelligence: 'artificial' }, cwd)
|
|
57
|
+
expect(isConformant(e3)).toBe(false) // no runtime/default_runtime at all
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('intelligence accepts the live vocab + legacy on read', () => {
|
|
61
|
+
for (const v of ['artificial', 'natural', 'absent', 'human', 'scripted']) {
|
|
62
|
+
const issues = validateProfileStandard({ default_runtime: 'claude', runtimes: ['claude'], intelligence: v }, cwd)
|
|
63
|
+
expect(issues.some(i => i.field === 'intelligence')).toBe(false)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('reconcileIndex / reindexFromLocals (self-heal)', () => {
|
|
69
|
+
let root: string
|
|
70
|
+
const prevRoot = process.env.IAPEER_ROOT
|
|
71
|
+
let borisCwd: string
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-ps-'))
|
|
75
|
+
process.env.IAPEER_ROOT = root
|
|
76
|
+
// a local profile (source of truth) with a FRESH description + narrowed runtimes
|
|
77
|
+
borisCwd = join(root, 'peers', 'boris')
|
|
78
|
+
mkdirSync(join(borisCwd, '.iapeer'), { recursive: true })
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(borisCwd, '.iapeer', 'peer-profile.json'),
|
|
81
|
+
JSON.stringify({ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'FRESH local desc', intelligence: 'artificial' }),
|
|
82
|
+
)
|
|
83
|
+
// a STALE index: old description + an extra runtime the local no longer declares
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(root, 'peers-profiles.json'),
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
version: 2,
|
|
88
|
+
peers: [{ personality: 'boris', runtime: 'claude', runtimes: ['claude', 'codex'], description: 'STALE index desc', intelligence: 'artificial', cwd: borisCwd }],
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
if (prevRoot === undefined) delete process.env.IAPEER_ROOT
|
|
94
|
+
else process.env.IAPEER_ROOT = prevRoot
|
|
95
|
+
rmSync(root, { recursive: true, force: true })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('reconcile DETECTS the drift (description + runtimes) read-only', () => {
|
|
99
|
+
const r = reconcileIndex({ env: process.env })
|
|
100
|
+
const boris = r.find(e => e.personality === 'boris')
|
|
101
|
+
expect(boris?.drift).toContain('description')
|
|
102
|
+
expect(boris?.drift).toContain('runtimes')
|
|
103
|
+
// read-only: the index file is untouched
|
|
104
|
+
expect(readPeersIndex({ env: process.env }).peers[0].description).toBe('STALE index desc')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('reindex SELF-HEALS the index from the local profile (REPLACE, not union)', async () => {
|
|
108
|
+
const { healed } = await reindexFromLocals({ env: process.env })
|
|
109
|
+
expect(healed.some(h => h.startsWith('boris:'))).toBe(true)
|
|
110
|
+
const rec = readPeersIndex({ env: process.env }).peers[0]
|
|
111
|
+
expect(rec.description).toBe('FRESH local desc')
|
|
112
|
+
expect(rec.runtimes).toEqual(['claude']) // codex DROPPED (reindex replaces, unlike upsert union)
|
|
113
|
+
// after the heal, reconcile is clean
|
|
114
|
+
expect(reconcileIndex({ env: process.env }).find(e => e.personality === 'boris')?.drift).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -55,10 +55,33 @@ function jqJson(value: string): string {
|
|
|
55
55
|
.replace(new RegExp(String.fromCharCode(0x00), 'g'), '\\u0000')
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** A per-file section: a path marker line, then the file content. The marker is an
|
|
59
|
+
* HTML comment — inert in rendered markdown yet plainly visible in the raw system-
|
|
60
|
+
* prompt the model reads (verified: the model's context surfaces HTML comments) —
|
|
61
|
+
* so a reader (human or agent) can tell, per file, exactly which path each chunk of
|
|
62
|
+
* the assembled prompt came from. No marker when `path` is absent (a pure-renderer
|
|
63
|
+
* caller, or the synthesized Layer-1 YAML / Layer-3 registry which are not files). */
|
|
64
|
+
function markedSection(path: string | undefined, content: string): string {
|
|
65
|
+
return path ? `<!-- ${path} -->\n${content}` : content
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Trailing-newline-trimmed length test: a 0-byte or newline-only file is "empty"
|
|
69
|
+
* and contributes no section (and so no dangling marker). */
|
|
70
|
+
function hasContent(s: string | undefined): s is string {
|
|
71
|
+
return s !== undefined && s.replace(/\n+$/, '').length > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
/**
|
|
59
|
-
* Compose the merged system prompt
|
|
60
|
-
*
|
|
61
|
-
*
|
|
75
|
+
* Compose the merged system prompt: the YAML identity block (Layer 1) followed by
|
|
76
|
+
* every file-sourced layer rendered PER FILE — each injected `.md` prefixed by a
|
|
77
|
+
* path marker (`<!-- <path> -->`). Layers, general → specific:
|
|
78
|
+
* 1. YAML identity block (synthesized, no marker) — byte-for-byte the jq layout.
|
|
79
|
+
* 2. IAPEER.md doctrine: global then local, each its own marked section.
|
|
80
|
+
* 3. Normalized peer registry (generated, no marker).
|
|
81
|
+
* 4. Other `<DOMAIN>.md` at the .iapeer root — each half (global, local) marked.
|
|
82
|
+
* 5. fragments/ `*.md` — each half marked. Most-volatile, sits LAST.
|
|
83
|
+
* Empty files contribute nothing (no section, no marker). Sections are joined by a
|
|
84
|
+
* single blank line and the output ends with exactly one newline.
|
|
62
85
|
*/
|
|
63
86
|
export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
64
87
|
// The eight identity lines, in the bash's exact key order. Keys verbatim:
|
|
@@ -77,38 +100,37 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
|
|
|
77
100
|
// jq's final "" array element → the bare blank line after the closing '---'.
|
|
78
101
|
'\n'
|
|
79
102
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
103
|
+
// The YAML identity block is the only Layer-1 section (synthesized, no marker).
|
|
104
|
+
const sections: string[] = [yaml]
|
|
105
|
+
|
|
106
|
+
// Layer 2 — IAPEER.md doctrine, GLOBAL then LOCAL (general → specific), each its
|
|
107
|
+
// OWN marked file-section. The bash existence-gate (present-but-empty global → a
|
|
108
|
+
// bare "\n") is dropped deliberately: an empty doctrine file now contributes no
|
|
109
|
+
// section (and no dangling marker), like every other empty file. IAPEER.md carries
|
|
110
|
+
// the only FIXED semantics in the assembly (host = general peer rules, local = this
|
|
111
|
+
// peer's rules — the CLAUDE.md analogue); its meaning is conveyed by the well-known
|
|
112
|
+
// filename + path, so the marker stays a bare path like every other file's.
|
|
113
|
+
if (hasContent(input.globalDoctrine)) {
|
|
114
|
+
sections.push(markedSection(input.globalDoctrinePath, input.globalDoctrine))
|
|
115
|
+
}
|
|
116
|
+
if (hasContent(input.peerDoctrine)) {
|
|
117
|
+
sections.push(markedSection(input.peerDoctrinePath, input.peerDoctrine))
|
|
118
|
+
}
|
|
90
119
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
120
|
+
// Layer 3 — normalized peer registry (generated from the index, not a file → no
|
|
121
|
+
// marker). Empty list → nothing.
|
|
122
|
+
const registry = renderRegistry(input.peers ?? [])
|
|
123
|
+
if (registry.length > 0) sections.push(registry)
|
|
95
124
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const layer3 = renderRegistry(input.peers ?? [])
|
|
101
|
-
const layer4 = renderMergedBlocks(input.pluginDomains ?? [])
|
|
102
|
-
const layer5 = renderMergedBlocks(input.promptFragments ?? [])
|
|
103
|
-
const sections = [layer12, layer3, layer4, layer5].filter(section => section.length > 0)
|
|
125
|
+
// Layers 4 (other <DOMAIN>.md) then 5 (fragments/, most-volatile, LAST) — each
|
|
126
|
+
// file half (global, local) is its own marked section. Empty halves drop out.
|
|
127
|
+
pushMarkedBlocks(sections, input.pluginDomains ?? [])
|
|
128
|
+
pushMarkedBlocks(sections, input.promptFragments ?? [])
|
|
104
129
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// trailing
|
|
108
|
-
//
|
|
109
|
-
// with a single '\n'. This makes the output robust to whether the source files
|
|
110
|
-
// carried trailing newlines (a present-but-newline-only tail never widens a gap).
|
|
111
|
-
if (sections.length === 1) return sections[0]
|
|
130
|
+
// Normalize every seam to exactly one blank line: strip each section's trailing
|
|
131
|
+
// newlines, join with '\n\n', end with a single '\n'. Robust to whether source
|
|
132
|
+
// files carried trailing newlines. A bare-doctrine peer is now [YAML, local
|
|
133
|
+
// IAPEER.md] → two sections, so it always carries its doctrine's path marker.
|
|
112
134
|
return sections.map(section => section.replace(/\n+$/, '')).join('\n\n') + '\n'
|
|
113
135
|
}
|
|
114
136
|
|
|
@@ -132,32 +154,23 @@ function renderRegistry(peers: readonly PublicPeerSummary[]): string {
|
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
-
// Layers 4 & 5 —
|
|
157
|
+
// Layers 4 & 5 — per-file marked sections:
|
|
136
158
|
// • Layer 4: every non-IAPEER `<DOMAIN>.md` pair at the .iapeer/ root (operator /
|
|
137
159
|
// plugin user-settings; SPAWNER_INSTRUCTIONS.md, … flow in organically).
|
|
138
160
|
// • Layer 5: every `<STEM>.md` pair in .iapeer/fragments/ (primitive-owned,
|
|
139
161
|
// machine-regenerated doctrine fragments — iapeer-memory's guide + note index).
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
162
|
+
// Each block contributes its GLOBAL half then its LOCAL half (general → specific),
|
|
163
|
+
// EACH as its own marked file-section (a 0-byte / newline-only file → no section,
|
|
164
|
+
// no dangling marker). Blocks are appended in the order given (the gatherer sorts
|
|
165
|
+
// by filename for determinism); the stem (`domain`) is used only for that ordering,
|
|
166
|
+
// never emitted.
|
|
145
167
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
168
|
|
|
147
|
-
function
|
|
148
|
-
const out: string[] = []
|
|
169
|
+
function pushMarkedBlocks(sections: string[], blocks: readonly PromptDomainBlock[]): void {
|
|
149
170
|
for (const b of blocks) {
|
|
150
|
-
|
|
151
|
-
|
|
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]
|
|
155
|
-
.filter((s): s is string => s !== undefined)
|
|
156
|
-
.map(s => s.replace(/\n+$/, ''))
|
|
157
|
-
.filter(s => s.length > 0)
|
|
158
|
-
if (halves.length > 0) out.push(halves.join('\n'))
|
|
171
|
+
if (hasContent(b.global)) sections.push(markedSection(b.globalPath, b.global))
|
|
172
|
+
if (hasContent(b.local)) sections.push(markedSection(b.localPath, b.local))
|
|
159
173
|
}
|
|
160
|
-
return out.join('\n\n')
|
|
161
174
|
}
|
|
162
175
|
|
|
163
176
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -218,16 +231,28 @@ function listDomainFiles(root: string): string[] {
|
|
|
218
231
|
return listMdFiles(root).filter(name => name.toLowerCase() !== doctrineLower)
|
|
219
232
|
}
|
|
220
233
|
|
|
234
|
+
/** Display path for a marker: an absolute path with a leading HOME abbreviated to
|
|
235
|
+
* `~` (the form a human scanning the assembled prompt expects). HOME unset / path
|
|
236
|
+
* outside HOME → the absolute path verbatim. */
|
|
237
|
+
function displayPath(absPath: string, env: NodeJS.ProcessEnv): string {
|
|
238
|
+
const home = env.HOME?.trim()
|
|
239
|
+
if (home && (absPath === home || absPath.startsWith(home + '/'))) {
|
|
240
|
+
return '~' + absPath.slice(home.length)
|
|
241
|
+
}
|
|
242
|
+
return absPath
|
|
243
|
+
}
|
|
244
|
+
|
|
221
245
|
/** Build a list of merged global/local blocks (Layer 4 OR Layer 5) from the
|
|
222
246
|
* union of `*.md` stems present in the global and/or local dir, sorted by
|
|
223
247
|
* filename (plain codepoint order) for a deterministic prompt. `lister` selects
|
|
224
248
|
* the candidate `.md` files — Layer 4 excludes the IAPEER.md doctrine, Layer 5
|
|
225
|
-
* takes every fragment.
|
|
226
|
-
*
|
|
227
|
-
* dropped by the renderer. */
|
|
249
|
+
* takes every fragment. Each present half carries its display path for the marker.
|
|
250
|
+
* A read that races a rotation (file removed between listdir and read) → that half
|
|
251
|
+
* is undefined and drops out; a 0-byte file → '' and is dropped by the renderer. */
|
|
228
252
|
function gatherMergedBlocks(
|
|
229
253
|
globalDir: string,
|
|
230
254
|
localDir: string,
|
|
255
|
+
env: NodeJS.ProcessEnv,
|
|
231
256
|
lister: (dir: string) => string[] = listMdFiles,
|
|
232
257
|
): PromptDomainBlock[] {
|
|
233
258
|
const names = new Set<string>([...lister(globalDir), ...lister(localDir)])
|
|
@@ -235,8 +260,14 @@ function gatherMergedBlocks(
|
|
|
235
260
|
const block: PromptDomainBlock = { domain: name.slice(0, -3) } // strip ".md"
|
|
236
261
|
const global = readFileIfPresent(join(globalDir, name))
|
|
237
262
|
const local = readFileIfPresent(join(localDir, name))
|
|
238
|
-
if (global !== undefined)
|
|
239
|
-
|
|
263
|
+
if (global !== undefined) {
|
|
264
|
+
block.global = global
|
|
265
|
+
block.globalPath = displayPath(join(globalDir, name), env)
|
|
266
|
+
}
|
|
267
|
+
if (local !== undefined) {
|
|
268
|
+
block.local = local
|
|
269
|
+
block.localPath = displayPath(join(localDir, name), env)
|
|
270
|
+
}
|
|
240
271
|
return block
|
|
241
272
|
})
|
|
242
273
|
}
|
|
@@ -246,16 +277,19 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
246
277
|
const globalRoot = opts.globalRoot ?? resolveGlobalRoot(env)
|
|
247
278
|
const localRoot = join(opts.cwd, IAPEER_DIR)
|
|
248
279
|
|
|
249
|
-
// Layer 2 — IAPEER.md merge.
|
|
250
|
-
// '' when present-but-empty
|
|
251
|
-
|
|
252
|
-
const
|
|
280
|
+
// Layer 2 — IAPEER.md merge. Each half carries its display path for the marker.
|
|
281
|
+
// Existence-gated: undefined when absent, '' when present-but-empty (an empty
|
|
282
|
+
// doctrine now contributes no section — no bare-"\n" legacy quirk).
|
|
283
|
+
const globalDoctrineFile = join(globalRoot, IAPEER_DOCTRINE_FILE)
|
|
284
|
+
const peerDoctrineFile = join(localRoot, IAPEER_DOCTRINE_FILE)
|
|
285
|
+
const globalDoctrine = readFileIfPresent(globalDoctrineFile)
|
|
286
|
+
const peerDoctrine = readFileIfPresent(peerDoctrineFile) ?? ''
|
|
253
287
|
|
|
254
288
|
// Layer 4 — union of every non-IAPEER domain present globally and/or locally,
|
|
255
289
|
// sorted by filename for a deterministic prompt. Empty (0-byte) files stay as
|
|
256
290
|
// presence markers — readFileIfPresent returns '', and the renderer drops empty
|
|
257
291
|
// halves, so a marker contributes no text/separator.
|
|
258
|
-
const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, listDomainFiles)
|
|
292
|
+
const pluginDomains = gatherMergedBlocks(globalRoot, localRoot, env, listDomainFiles)
|
|
259
293
|
|
|
260
294
|
// Layer 5 — doctrine fragments: every `*.md` in the fragments/ subdir of both
|
|
261
295
|
// roots, merged per stem (global guide → per-peer specifics). Primitive-owned and
|
|
@@ -264,6 +298,7 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
264
298
|
const promptFragments = gatherMergedBlocks(
|
|
265
299
|
join(globalRoot, FRAGMENTS_DIR),
|
|
266
300
|
join(localRoot, FRAGMENTS_DIR),
|
|
301
|
+
env,
|
|
267
302
|
)
|
|
268
303
|
|
|
269
304
|
// Layer 3 — peers projected through the shared normalizer, sorted by personality
|
|
@@ -285,7 +320,10 @@ export function gatherPromptInput(opts: GatherPromptOptions): ComposePromptInput
|
|
|
285
320
|
hostname: opts.hostname,
|
|
286
321
|
today: opts.today,
|
|
287
322
|
peerDoctrine,
|
|
288
|
-
|
|
323
|
+
peerDoctrinePath: displayPath(peerDoctrineFile, env),
|
|
324
|
+
...(globalDoctrine !== undefined
|
|
325
|
+
? { globalDoctrine, globalDoctrinePath: displayPath(globalDoctrineFile, env) }
|
|
326
|
+
: {}),
|
|
289
327
|
peers,
|
|
290
328
|
pluginDomains,
|
|
291
329
|
promptFragments,
|
package/src/launch/types.ts
CHANGED
|
@@ -42,14 +42,21 @@ export type { PublicPeerSummary } from '../registry/index.ts'
|
|
|
42
42
|
/** One merged block: the global + local halves of a single stem (either may be
|
|
43
43
|
* absent), used for BOTH Layer 4 (`<DOMAIN>.md` at the .iapeer/ root) and Layer 5
|
|
44
44
|
* (`<STEM>.md` in .iapeer/fragments/). `domain` is the filename stem, used only
|
|
45
|
-
* for stable ordering — it is NOT emitted
|
|
46
|
-
*
|
|
45
|
+
* for stable ordering — it is NOT emitted. Within a block global precedes local
|
|
46
|
+
* (general → specific), and each half is rendered as its OWN per-file section
|
|
47
|
+
* prefixed by a path marker (`globalPath` / `localPath`) — the assembly is now
|
|
48
|
+
* per-file, not an organic stem-merge. */
|
|
47
49
|
export interface PromptDomainBlock {
|
|
48
50
|
domain: string
|
|
49
51
|
/** Global half content (general): ~/.iapeer/<DOMAIN>.md or ~/.iapeer/fragments/<STEM>.md. */
|
|
50
52
|
global?: string
|
|
51
53
|
/** Local half content (specific — overrides global): <cwd>/.iapeer/… counterpart. */
|
|
52
54
|
local?: string
|
|
55
|
+
/** Display path for the global half's marker (HOME abbreviated to ~). Absent →
|
|
56
|
+
* no marker (pure-renderer callers). */
|
|
57
|
+
globalPath?: string
|
|
58
|
+
/** Display path for the local half's marker. */
|
|
59
|
+
localPath?: string
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface ComposePromptInput {
|
|
@@ -64,10 +71,14 @@ export interface ComposePromptInput {
|
|
|
64
71
|
today: string
|
|
65
72
|
/** Layer 2 local: <cwd>/.iapeer/IAPEER.md content. '' when absent. */
|
|
66
73
|
peerDoctrine: string
|
|
74
|
+
/** Display path for the local IAPEER.md marker (HOME abbreviated to ~). */
|
|
75
|
+
peerDoctrinePath?: string
|
|
67
76
|
/** Layer 2 global, OPTIONAL: ~/.iapeer/IAPEER.md content (sits between the YAML
|
|
68
77
|
* block and the per-peer doctrine so per-peer overrides global). Existence-
|
|
69
78
|
* gated: a present-but-empty file → '', an absent file → undefined. */
|
|
70
79
|
globalDoctrine?: string
|
|
80
|
+
/** Display path for the global IAPEER.md marker. */
|
|
81
|
+
globalDoctrinePath?: string
|
|
71
82
|
/** Layer 3: the normalized peer registry. Empty/omitted → the layer emits
|
|
72
83
|
* nothing (and the output stays byte-identical to the legacy YAML+doctrine). */
|
|
73
84
|
peers?: PublicPeerSummary[]
|
package/src/registry/index.ts
CHANGED
|
@@ -158,14 +158,17 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
158
158
|
)}"`,
|
|
159
159
|
)
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// default_runtime is the contract field; `runtime` is legacy. Dual-read accepts
|
|
162
|
+
// both during the staged migration (write still emits `runtime` until Phase 2).
|
|
163
|
+
const defaultRuntime = obj.default_runtime ?? obj.runtime
|
|
164
|
+
if (!isRuntime(defaultRuntime)) {
|
|
162
165
|
throw new IapError(
|
|
163
|
-
`peers-profiles.json corrupted: peer "${obj.personality}"
|
|
166
|
+
`peers-profiles.json corrupted: peer "${obj.personality}" default_runtime must match /^[a-z][a-z0-9]{0,31}$/`,
|
|
164
167
|
)
|
|
165
168
|
}
|
|
166
169
|
const runtimes = Array.isArray(obj.runtimes)
|
|
167
|
-
? ensureRuntimes(obj.runtimes.filter(item => typeof item === 'string') as string[],
|
|
168
|
-
: [
|
|
170
|
+
? ensureRuntimes(obj.runtimes.filter(item => typeof item === 'string') as string[], defaultRuntime)
|
|
171
|
+
: [defaultRuntime]
|
|
169
172
|
const { description } = clampDescription(typeof obj.description === 'string' ? obj.description : '')
|
|
170
173
|
if (typeof obj.cwd !== 'string' || !obj.cwd.trim()) {
|
|
171
174
|
throw new IapError(`peers-profiles.json corrupted: peer "${obj.personality}" cwd is required`)
|
|
@@ -177,7 +180,7 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
177
180
|
let intelligence: Intelligence
|
|
178
181
|
let intelligenceRaw: string | undefined
|
|
179
182
|
if (obj.intelligence === undefined || obj.intelligence === null || obj.intelligence === '') {
|
|
180
|
-
intelligence = defaultIntelligenceForRuntime(
|
|
183
|
+
intelligence = defaultIntelligenceForRuntime(defaultRuntime)
|
|
181
184
|
// no raw on disk → a healed default will be persisted
|
|
182
185
|
} else {
|
|
183
186
|
const normalized = normalizeIntelligenceValue(obj.intelligence)
|
|
@@ -192,7 +195,7 @@ function normalizePeer(raw: unknown): PeerRecord {
|
|
|
192
195
|
const interfaces = normalizeInterfaces(obj.interfaces, obj.personality)
|
|
193
196
|
return {
|
|
194
197
|
personality: obj.personality,
|
|
195
|
-
runtime:
|
|
198
|
+
runtime: defaultRuntime,
|
|
196
199
|
runtimes,
|
|
197
200
|
description,
|
|
198
201
|
intelligence,
|