@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
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` table shape (whitespace-aligned columns)
48
- const CODEX_LIST = `Marketplace \`agfpd\`
49
-
50
- PLUGIN STATUS VERSION PATH
51
- spawned-peer@agfpd not installed https://github.com/agfpd/Spawned-Peer.git
52
- peer-voice@agfpd installed, enabled 0.1.8 https://github.com/agfpd/Peer-Voice.git
53
- MergeMind@agfpd installed, disabled 0.14.70 https://github.com/agfpd/MergeMind.git`
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 STATUS column per plugin id', () => {
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') // "not installed"
59
- expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled')
60
- expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in list
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
 
@@ -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 a `codex plugin list` STATUS column for one plugin id. The table is
278
- * whitespace-aligned: `<id> <status> <version> <path>`; status is `not installed`
279
- * / `installed, enabled` / `installed, disabled`. PURE → unit-testable. */
280
- export function parseCodexPluginStatus(listOutput: string, id: string): 'enabled' | 'disabled' | 'absent' {
281
- for (const line of listOutput.split('\n')) {
282
- const cols = line.split(/\s{2,}/).map(c => c.trim())
283
- if (cols[0] !== id) continue
284
- const status = (cols[1] ?? '').toLowerCase()
285
- if (status.includes('not installed')) return 'absent'
286
- if (status.includes('enabled')) return 'enabled'
287
- if (status.includes('installed')) return 'disabled'
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
- return 'absent'
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
 
@@ -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
- const runtime = validateRuntime(obj.runtime, 'peer-profile runtime')
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
- 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
+ })
@@ -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 (YAML identity block + optional global
60
- * doctrine + per-peer doctrine), GOLDEN byte-for-byte equivalent to the
61
- * claude-start.sh:264-290 jq pipeline.
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
- // Global doctrine sits between the YAML block and the per-peer doctrine so
81
- // per-peer rules override global (specific over general). Bash gates on file
82
- // EXISTENCE (`[ -f "$GLOBAL_DOCTRINE" ]`), NOT non-emptiness — a present but
83
- // EMPTY global file still emits `cat "" + printf "\n"` = "\n". So the contract
84
- // is: a present file → globalDoctrine is the string (possibly ""), absent file
85
- // → undefined. Guard on `!== undefined` (NOT truthiness) to match bash exactly;
86
- // an empty "" then yields "" + "\n" = "\n" (the 1-byte golden divergence the
87
- // adversarial verify caught). The trailing "\n" mirrors the unconditional
88
- // `printf "\n"` after `cat "$GLOBAL"`.
89
- const global = input.globalDoctrine !== undefined ? input.globalDoctrine + '\n' : ''
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
- // Layers 1+2: YAML block + global doctrine + per-peer doctrine verbatim
92
- // (`cat "$DOCTRINE"`), no trailing newline added. BYTE-IDENTICAL to the legacy
93
- // jq output — the golden test pins this exactly.
94
- const layer12 = yaml + global + input.peerDoctrine
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
- // Layer 3 (registry), Layer 4 (operator/plugin domains) and Layer 5 (primitive
97
- // doctrine fragments) are appended ONLY when they have content. Layer 5 sits LAST
98
- // — it is the most volatile (machine-regenerated on every source change), so it
99
- // never disturbs the stable prefix (YAML + doctrine + registry + domains) above it.
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
- // ONE section (no registry, no extra domains) → the legacy bytes, UNTOUCHED —
106
- // the golden test pins layer12 exactly (peerDoctrine verbatim, incl. its own
107
- // trailing-newline-or-not). MULTIPLE sections → normalize every seam to exactly
108
- // one blank line: strip each section's trailing newlines, join with '\n\n', end
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 — merged global/local blocks. Shared renderer:
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
- // Both merge general → specific (global then local). The merge is ORGANIC: stems
141
- // are used only for stable ordering (done by the gatherer), never emitted. An empty
142
- // file is a presence marker (Канал B) and contributes no text — so a fragment dir
143
- // that drops to a 0-byte placeholder, or whose file vanishes mid-rotation, yields
144
- // nothing here rather than a dangling separator.
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 renderMergedBlocks(blocks: readonly PromptDomainBlock[]): string {
148
- const out: string[] = []
169
+ function pushMarkedBlocks(sections: string[], blocks: readonly PromptDomainBlock[]): void {
149
170
  for (const b of blocks) {
150
- // Per stem: global then local (general → specific), each trimmed of its
151
- // trailing newlines so a 0-byte marker (or a newline-only file) drops out and
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. A read that races a rotation (file removed between listdir
226
- * and read) → that half is undefined and drops out; a 0-byte file → '' and is
227
- * dropped by the renderer. */
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) block.global = global
239
- if (local !== undefined) block.local = local
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. Global is existence-gated (undefined when absent,
250
- // '' when present-but-empty — golden parity). Local defaults to '' when absent.
251
- const globalDoctrine = readFileIfPresent(join(globalRoot, IAPEER_DOCTRINE_FILE))
252
- const peerDoctrine = readFileIfPresent(join(localRoot, IAPEER_DOCTRINE_FILE)) ?? ''
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
- ...(globalDoctrine !== undefined ? { globalDoctrine } : {}),
323
+ peerDoctrinePath: displayPath(peerDoctrineFile, env),
324
+ ...(globalDoctrine !== undefined
325
+ ? { globalDoctrine, globalDoctrinePath: displayPath(globalDoctrineFile, env) }
326
+ : {}),
289
327
  peers,
290
328
  pluginDomains,
291
329
  promptFragments,
@@ -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 (the merge is organic). Within a block
46
- * global precedes local (general → specific). */
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[]
@@ -158,14 +158,17 @@ function normalizePeer(raw: unknown): PeerRecord {
158
158
  )}"`,
159
159
  )
160
160
  }
161
- if (!isRuntime(obj.runtime)) {
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}" runtime must match /^[a-z][a-z0-9]{0,31}$/`,
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[], obj.runtime)
168
- : [obj.runtime]
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(obj.runtime)
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: obj.runtime,
198
+ runtime: defaultRuntime,
196
199
  runtimes,
197
200
  description,
198
201
  intelligence,