@agfpd/iapeer 0.2.37 → 0.2.39

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.37",
3
+ "version": "0.2.39",
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
@@ -48,6 +48,7 @@ import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
48
48
  import { peerProfilePath } from '../storage/index.ts'
49
49
  import {
50
50
  isConformant,
51
+ migrateProfileRuntimeField,
51
52
  reconcileIndex,
52
53
  reindexFromLocals,
53
54
  validateProfileStandard,
@@ -554,7 +555,7 @@ const USAGE = `usage: iapeer <verb> [args]
554
555
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
555
556
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
556
557
  list [--json] registered peers + per-runtime liveness
557
- verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index)
558
+ verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)
558
559
  stop <peer> [runtime] | --all warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it
559
560
  until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
560
561
  start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
@@ -886,12 +887,18 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
886
887
  }
887
888
  case 'verify': {
888
889
  // Profile conformance + index↔local self-heal reconciliation. Read-only by
889
- // default; --fix self-heals the index (reindex from local profiles). A drift
890
- // is a signal that the index self-heal lapsed — caught by construction here.
890
+ // default; --fix self-heals the index (reindex from local profiles) AND
891
+ // migrates conformant local profiles off the legacy `runtime` field shape
892
+ // (Phase 2 of the staged default_runtime story). A drift is a signal that
893
+ // the index self-heal lapsed — caught by construction here.
891
894
  const index = readPeersIndex({ env })
892
895
  let errors = 0
893
896
  let driftCount = 0
894
897
  const lines: string[] = []
898
+ // Conformant profiles still carrying the LEGACY runtime field shape (no
899
+ // default_runtime, or a diverged mirror) — candidates for the --fix data
900
+ // migration (Phase 2 of the staged default_runtime story).
901
+ const legacyRuntimeProfiles: Array<{ personality: string; cwd: string }> = []
895
902
  for (const peer of index.peers) {
896
903
  const path = peerProfilePath(peer.cwd)
897
904
  if (!existsSync(path)) {
@@ -916,6 +923,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
916
923
  } else if (warns.length > 0 && flags.json !== true) {
917
924
  lines.push(`⚠ ${peer.personality}: ${warns.map(i => i.field).join(', ')}`)
918
925
  }
926
+ // Migration candidate: conformant, but the default_runtime warn fired
927
+ // (legacy-only field or diverged mirror). Errored profiles are NEVER
928
+ // rewritten — migration heals shape, it does not guess at broken data.
929
+ if (errs.length === 0 && warns.some(i => i.field === 'default_runtime')) {
930
+ legacyRuntimeProfiles.push({ personality: peer.personality, cwd: peer.cwd })
931
+ }
919
932
  }
920
933
  const reconcile = reconcileIndex({ env })
921
934
  for (const r of reconcile) {
@@ -932,10 +945,23 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
932
945
  for (const l of lines) out(l + '\n')
933
946
  out(`\n${index.peers.length} peers · ${errors} error(s) · ${driftCount} index↔local drift(s)\n`)
934
947
  }
935
- if (flags.fix === true && (driftCount > 0 || errors === 0)) {
936
- const { healed, missing } = await reindexFromLocals({ env })
937
- if (healed.length > 0) out(`self-healed index from local profiles:\n ${healed.join('\n ')}\n`)
938
- if (missing.length > 0) errOut(`peers with no local profile (left untouched): ${missing.join(', ')}\n`)
948
+ if (flags.fix === true) {
949
+ // Phase-2 data migration (staged default_runtime story): rewrite conformant
950
+ // local profiles still on the legacy field shape — the writer now emits
951
+ // `default_runtime` + the in-sync legacy `runtime` mirror. Runs BEFORE the
952
+ // reindex so the healed index is projected from migrated locals.
953
+ const migrated: string[] = []
954
+ for (const p of legacyRuntimeProfiles) {
955
+ if (migrateProfileRuntimeField(p.cwd)) migrated.push(p.personality)
956
+ }
957
+ if (migrated.length > 0) {
958
+ out(`migrated runtime→default_runtime (legacy mirror kept): ${migrated.join(', ')}\n`)
959
+ }
960
+ if (driftCount > 0 || errors === 0) {
961
+ const { healed, missing } = await reindexFromLocals({ env })
962
+ if (healed.length > 0) out(`self-healed index from local profiles:\n ${healed.join('\n ')}\n`)
963
+ if (missing.length > 0) errOut(`peers with no local profile (left untouched): ${missing.join(', ')}\n`)
964
+ }
939
965
  }
940
966
  return errors > 0 || (driftCount > 0 && flags.fix !== true) ? 1 : 0
941
967
  }
@@ -292,6 +292,12 @@ export function writePeerProfileAtomic(cwd: string, profile: PeerProfileWrite):
292
292
  const merged: Record<string, unknown> = {
293
293
  ...existing,
294
294
  personality: profile.personality,
295
+ // Phase-2 write-flip (contract Идентичность, staged default_runtime migration):
296
+ // emit the contract field `default_runtime` AND keep the legacy `runtime` as an
297
+ // IN-SYNC MIRROR — readers that still read `runtime` only (IAP peers.ts,
298
+ // Spawned-Peer, external scripts) stay alive with zero blast radius. Phase 3
299
+ // (gated on every reader shipping dual-read) removes the mirror.
300
+ default_runtime: profile.runtime,
295
301
  runtime: profile.runtime,
296
302
  runtimes: profile.runtimes,
297
303
  description,
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
4
4
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
5
5
  import { tmpdir } from 'os'
6
6
  import { join } from 'path'
7
- import { isConformant, reconcileIndex, reindexFromLocals, validateProfileStandard } from './profileStandard.ts'
7
+ import { isConformant, migrateProfileRuntimeField, reconcileIndex, reindexFromLocals, validateProfileStandard } from './profileStandard.ts'
8
+ import { readFileSync } from 'fs'
8
9
  import { readPeersIndex } from '../registry/index.ts'
9
10
 
10
11
  describe('validateProfileStandard', () => {
@@ -28,6 +29,47 @@ describe('validateProfileStandard', () => {
28
29
  expect(issues.find(i => i.field === 'default_runtime')?.severity).toBe('warn')
29
30
  })
30
31
 
32
+ test('default_runtime + in-sync legacy mirror (Phase-2 write shape) → NO issues', () => {
33
+ const issues = validateProfileStandard(
34
+ { personality: 'boris', default_runtime: 'claude', runtime: 'claude', runtimes: ['claude'], description: 'PM', intelligence: 'artificial' },
35
+ cwd,
36
+ )
37
+ expect(issues).toEqual([])
38
+ })
39
+
40
+ test('DIVERGED legacy mirror → WARN on default_runtime, still conformant', () => {
41
+ const issues = validateProfileStandard(
42
+ { personality: 'boris', default_runtime: 'codex', runtime: 'claude', runtimes: ['codex', 'claude'], description: '', intelligence: 'artificial' },
43
+ cwd,
44
+ )
45
+ expect(isConformant(issues)).toBe(true)
46
+ const warn = issues.find(i => i.field === 'default_runtime')
47
+ expect(warn?.severity).toBe('warn')
48
+ expect(warn?.message).toContain('diverged')
49
+ })
50
+
51
+ test('aliases misfiled in interfaces.telegram → WARN (owner migration to expansion.aliases pending)', () => {
52
+ const issues = validateProfileStandard(
53
+ {
54
+ personality: 'boris', default_runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial',
55
+ interfaces: { telegram: { bot: 'k', bot_username: 'x_bot', aliases: { '/alias-new': 'txt' } } },
56
+ },
57
+ cwd,
58
+ )
59
+ expect(isConformant(issues)).toBe(true)
60
+ expect(issues.some(i => i.field === 'interfaces.telegram.aliases' && i.severity === 'warn')).toBe(true)
61
+ // passport-only telegram section → silent
62
+ const clean = validateProfileStandard(
63
+ {
64
+ personality: 'boris', default_runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial',
65
+ interfaces: { telegram: { bot: 'k', bot_username: 'x_bot' } },
66
+ expansion: { aliases: { '/alias-new': 'txt' } },
67
+ },
68
+ cwd,
69
+ )
70
+ expect(clean).toEqual([])
71
+ })
72
+
31
73
  test('top-level aliases → WARN (owner migration pending), still conformant', () => {
32
74
  const issues = validateProfileStandard(
33
75
  { personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', aliases: { '/new': 'x' } },
@@ -113,4 +155,50 @@ describe('reconcileIndex / reindexFromLocals (self-heal)', () => {
113
155
  // after the heal, reconcile is clean
114
156
  expect(reconcileIndex({ env: process.env }).find(e => e.personality === 'boris')?.drift).toEqual([])
115
157
  })
158
+
159
+ test('reindex writes the Phase-2 disk shape: default_runtime + in-sync legacy mirror', async () => {
160
+ await reindexFromLocals({ env: process.env })
161
+ const disk = JSON.parse(readFileSync(join(root, 'peers-profiles.json'), 'utf8'))
162
+ expect(disk.peers[0].default_runtime).toBe('claude')
163
+ expect(disk.peers[0].runtime).toBe('claude') // legacy mirror until Phase 3
164
+ })
165
+
166
+ test('migrateProfileRuntimeField: legacy-only profile → rewritten with default_runtime + mirror, owner fields preserved, idempotent', () => {
167
+ const profilePath = join(borisCwd, '.iapeer', 'peer-profile.json')
168
+ // enrich the legacy profile with owner-section + lifecycle fields that MUST survive
169
+ writeFileSync(
170
+ profilePath,
171
+ JSON.stringify({
172
+ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: 'd', intelligence: 'human',
173
+ initial_prompt: 'boot', wake_policy: 'ephemeral',
174
+ interfaces: { telegram: { bot: 'k' } },
175
+ expansion: { aliases: { '/alias-new': 'txt' } },
176
+ notifier: { triggers: [] },
177
+ }),
178
+ )
179
+ expect(migrateProfileRuntimeField(borisCwd)).toBe(true)
180
+ const after = JSON.parse(readFileSync(profilePath, 'utf8'))
181
+ expect(after.default_runtime).toBe('claude')
182
+ expect(after.runtime).toBe('claude') // in-sync legacy mirror
183
+ expect(after.intelligence).toBe('human') // legacy vocab preserved VERBATIM (no silent migration)
184
+ expect(after.initial_prompt).toBe('boot')
185
+ expect(after.wake_policy).toBe('ephemeral')
186
+ expect(after.interfaces).toEqual({ telegram: { bot: 'k' } })
187
+ expect(after.expansion).toEqual({ aliases: { '/alias-new': 'txt' } })
188
+ expect(after.notifier).toEqual({ triggers: [] })
189
+ // idempotent: already in shape → untouched
190
+ expect(migrateProfileRuntimeField(borisCwd)).toBe(false)
191
+ })
192
+
193
+ test('migrateProfileRuntimeField: DIVERGED mirror heals to default_runtime (the contract side wins)', () => {
194
+ const profilePath = join(borisCwd, '.iapeer', 'peer-profile.json')
195
+ writeFileSync(
196
+ profilePath,
197
+ JSON.stringify({ personality: 'boris', default_runtime: 'codex', runtime: 'claude', runtimes: ['codex', 'claude'], description: '', intelligence: 'artificial' }),
198
+ )
199
+ expect(migrateProfileRuntimeField(borisCwd)).toBe(true)
200
+ const after = JSON.parse(readFileSync(profilePath, 'utf8'))
201
+ expect(after.default_runtime).toBe('codex')
202
+ expect(after.runtime).toBe('codex') // mirror re-synced to the governing field
203
+ })
116
204
  })
@@ -8,16 +8,20 @@
8
8
  // ORDER = preference, first = primary), description, intelligence ∈
9
9
  // {artificial|natural|absent}. Optional lifecycle: initial_prompt, wake_policy.
10
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.
11
+ // (telegram/capability passport — PASSPORT ONLY: aliases are misfiled there and the
12
+ // contract relocates them to `expansion.aliases`, the runtime-agnostic plugin-config
13
+ // section owned by the chat-runtime — telegram-runtime today, future discord/matrix
14
+ // read the SAME section per the 25.05 expansion design), notifier (notifier-runtime),
15
+ // expansion (chat-runtime aliases), <plugin> sections — each plugin writes only its
16
+ // own field via atomic merge. Plugin-config sections are NOT projected into the
17
+ // registry index (private config is read from the LOCAL profile by its owner).
15
18
  //
16
19
  // peers-profiles.json is a regenerable self-heal PROJECTION of the local profiles —
17
20
  // projectProfileToRecord builds the record the index SHOULD hold; verify compares the
18
21
  // live index against it, reindex rewrites it. The index is never the source of truth.
19
22
 
20
23
  import { basename } from 'path'
24
+ import { existsSync, readFileSync } from 'fs'
21
25
  import { isRuntime, normalizeIntelligenceValue, normalizeNameCandidate } from '../core/constants.ts'
22
26
  import {
23
27
  clampDescription,
@@ -26,7 +30,8 @@ import {
26
30
  type PeerRecord,
27
31
  type PeersUpdateOptions,
28
32
  } from '../registry/index.ts'
29
- import { readPeerProfile } from './index.ts'
33
+ import { peerProfilePath } from '../storage/index.ts'
34
+ import { readPeerProfile, writePeerProfileAtomic } from './index.ts'
30
35
 
31
36
  export type IssueSeverity = 'error' | 'warn'
32
37
  export interface ProfileIssue {
@@ -60,12 +65,21 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
60
65
  })
61
66
  }
62
67
 
63
- // default_runtime (contract) / runtime (legacy, staged migration).
68
+ // default_runtime (contract) / runtime (legacy, staged migration). Since the Phase-2
69
+ // write-flip the writer emits BOTH (legacy `runtime` as an in-sync mirror); a profile
70
+ // carrying only the legacy name, or a DIVERGED mirror (a legacy writer updated
71
+ // `runtime` behind the contract field — dual-read ignores it), is a migration WARN.
64
72
  const dr = obj.default_runtime ?? obj.runtime
65
73
  if (!isRuntime(dr)) {
66
74
  issues.push({ severity: 'error', field: 'default_runtime', message: `must be a valid runtime id, got "${String(dr ?? '')}"` })
67
75
  } else if (obj.default_runtime === undefined) {
68
76
  issues.push({ severity: 'warn', field: 'default_runtime', message: 'legacy field name `runtime` — standard is `default_runtime` (staged migration)' })
77
+ } else if (obj.runtime !== undefined && obj.runtime !== obj.default_runtime) {
78
+ issues.push({
79
+ severity: 'warn',
80
+ field: 'default_runtime',
81
+ message: `legacy mirror \`runtime\`="${String(obj.runtime)}" diverged from default_runtime="${String(obj.default_runtime)}" — default_runtime governs; re-run \`iapeer verify --fix\` to re-sync the mirror`,
82
+ })
69
83
  }
70
84
 
71
85
  // runtimes[] — array of valid runtime ids that includes the default_runtime.
@@ -106,14 +120,35 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
106
120
  if (obj.interfaces !== undefined && (typeof obj.interfaces !== 'object' || obj.interfaces === null || Array.isArray(obj.interfaces))) {
107
121
  issues.push({ severity: 'error', field: 'interfaces', message: 'must be a JSON object' })
108
122
  }
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.
123
+ // Legacy top-level aliases — contract relocates to the `expansion.aliases` plugin-
124
+ // config section with the /alias-* namespace (clean slash-keys are reserved for the
125
+ // control layer). telegram-runtime owns that migration; flagged, not an error.
112
126
  if (obj.aliases !== undefined) {
113
127
  issues.push({
114
128
  severity: 'warn',
115
129
  field: 'aliases',
116
- message: 'legacy top-level `aliases` — contract relocates to interfaces.telegram.aliases (/alias-* namespace); telegram-runtime migration pending',
130
+ message: 'legacy top-level `aliases` — contract relocates to expansion.aliases (/alias-* namespace); owner migration pending',
131
+ })
132
+ }
133
+ // aliases misfiled in the PASSPORT (interfaces.telegram) — the 07.06 interim home.
134
+ // Taxonomy revision (11.06, Артур): aliases are PRIVATE plugin config (one reader,
135
+ // the chat-runtime at expansion), not a public passport attribute — the contract
136
+ // relocates them to the runtime-agnostic `expansion.aliases` section (25.05 замысел:
137
+ // future discord/matrix runtimes read the SAME aliases). Transition observability:
138
+ // this warn goes silent once the owner migration moves the data.
139
+ const interfacesObj =
140
+ obj.interfaces && typeof obj.interfaces === 'object' && !Array.isArray(obj.interfaces)
141
+ ? (obj.interfaces as Record<string, unknown>)
142
+ : undefined
143
+ const telegramSection =
144
+ interfacesObj?.telegram && typeof interfacesObj.telegram === 'object' && !Array.isArray(interfacesObj.telegram)
145
+ ? (interfacesObj.telegram as Record<string, unknown>)
146
+ : undefined
147
+ if (telegramSection?.aliases !== undefined) {
148
+ issues.push({
149
+ severity: 'warn',
150
+ field: 'interfaces.telegram.aliases',
151
+ message: 'aliases are plugin config, not passport — contract relocates to expansion.aliases; owner migration pending',
117
152
  })
118
153
  }
119
154
 
@@ -125,6 +160,38 @@ export function isConformant(issues: readonly ProfileIssue[]): boolean {
125
160
  return !issues.some(i => i.severity === 'error')
126
161
  }
127
162
 
163
+ /**
164
+ * Phase-2 DATA migration of the staged default_runtime story: rewrite the LOCAL
165
+ * profile at `cwd` so it carries the contract field `default_runtime` with the legacy
166
+ * `runtime` kept as an in-sync mirror (writePeerProfileAtomic emits both since the
167
+ * write-flip). Heals BOTH legacy shapes: a profile with only `runtime`, and a DIVERGED
168
+ * mirror — the rewrite goes through readPeerProfile's dual-read, so default_runtime
169
+ * wins divergence (the contract: default_runtime governs the primary). Owner sections
170
+ * (interfaces / notifier / expansion / …), initial_prompt, wake_policy and the raw
171
+ * legacy intelligence vocab are preserved verbatim by the H1 merge-write. Returns true
172
+ * when the file was rewritten; false when already in shape, absent, or unparseable
173
+ * (verify reports those as errors — migration never guesses).
174
+ */
175
+ export function migrateProfileRuntimeField(cwd: string): boolean {
176
+ const path = peerProfilePath(cwd)
177
+ if (!existsSync(path)) return false
178
+ let raw: unknown
179
+ try {
180
+ raw = JSON.parse(readFileSync(path, 'utf8'))
181
+ } catch {
182
+ return false
183
+ }
184
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false
185
+ const obj = raw as Record<string, unknown>
186
+ const inShape =
187
+ isRuntime(obj.default_runtime) && (obj.runtime === undefined || obj.runtime === obj.default_runtime)
188
+ if (inShape) return false
189
+ const profile = readPeerProfile(cwd)
190
+ if (!profile) return false
191
+ writePeerProfileAtomic(cwd, profile)
192
+ return true
193
+ }
194
+
128
195
  /**
129
196
  * The PeerRecord the index SHOULD hold for the peer at `cwd`, projected from its LOCAL
130
197
  * profile (the self-heal source of truth) — readPeerProfile already self-heals the
@@ -290,7 +290,11 @@ describe('wakeOrSpawn H4 refusal', () => {
290
290
  // ─────────────────────────────────────────────────────────────────────────────
291
291
 
292
292
  describe('wakeOrSpawn live-session fast path', () => {
293
- test('READY with taskDelivered:false — the winning wake delivered only ITS envelope', async () => {
293
+ // Live tmux required (spawns a real session). Skipped where tmux is absent — a
294
+ // clean CI runner has none; the foundation's preversion gate runs it locally
295
+ // (this machine has tmux), so the release path keeps the coverage.
296
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
297
+ test.if(tmuxAvailable)('READY with taskDelivered:false — the winning wake delivered only ITS envelope', async () => {
294
298
  const root = mkdtempSync(join(tmpdir(), 'iapeer-fpl-root-'))
295
299
  const laDir = mkdtempSync(join(tmpdir(), 'iapeer-fpl-la-')) // empty → not launchd-managed
296
300
  const sockDir = join(root, 'socks')
@@ -159,7 +159,8 @@ function normalizePeer(raw: unknown): PeerRecord {
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).
162
+ // both during the staged migration; when both are present default_runtime WINS
163
+ // (the write emits default_runtime + the legacy mirror since Phase 2).
163
164
  const defaultRuntime = obj.default_runtime ?? obj.runtime
164
165
  if (!isRuntime(defaultRuntime)) {
165
166
  throw new IapError(
@@ -314,9 +315,21 @@ function writePeersIndexAtomic(paths: PeersPaths, index: PeersIndex): void {
314
315
  // Persist the RAW intelligence verbatim (legacy-safe round-trip) and DROP the
315
316
  // intelligenceRaw shadow field from the on-disk shape — the file carries the
316
317
  // legacy vocab the live IAP reads, never the foundation's in-memory normalization.
318
+ // Phase-2 write-flip (staged default_runtime migration): the disk shape carries the
319
+ // contract field `default_runtime` PLUS the legacy `runtime` as an in-sync mirror,
320
+ // so not-yet-dual-read readers keep working. Phase 3 removes the mirror.
317
321
  const peersForDisk = [...index.peers]
318
322
  .sort((a, b) => a.personality.localeCompare(b.personality))
319
- .map(({ intelligenceRaw, ...peer }) => ({ ...peer, intelligence: intelligenceRaw ?? peer.intelligence }))
323
+ .map(peer => ({
324
+ personality: peer.personality,
325
+ default_runtime: peer.runtime,
326
+ runtime: peer.runtime,
327
+ runtimes: peer.runtimes,
328
+ description: peer.description,
329
+ intelligence: peer.intelligenceRaw ?? peer.intelligence,
330
+ cwd: peer.cwd,
331
+ ...(peer.interfaces ? { interfaces: peer.interfaces } : {}),
332
+ }))
320
333
  const normalized = { version: PEERS_SCHEMA_VERSION, peers: peersForDisk }
321
334
  writeFileSync(tmp, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 })
322
335
  renameSync(tmp, paths.peersFile)
@@ -487,6 +487,46 @@ function validateAttachments(value: readonly string[] = []): Result<string[]> {
487
487
  return ok(out)
488
488
  }
489
489
 
490
+ // ─── Telegram-domain sender policy (defect track 11.06, sanctioned by Артур) ─
491
+
492
+ /** A SENDER face in the telegram domain: a non-empty interfaces.telegram.bot (the
493
+ * bot machine-key) in the peer's registry passport. Deliberately bot-ONLY, not
494
+ * bot|user_id — the bridge picks the SENDING bot from the sender's bot key
495
+ * (owner fact, telegram-runtime сверка 11.06): a user_id grants a receive/operator
496
+ * identity but no ability to send, so a user_id-only sender passing the guard
497
+ * would still die at the bridge — a late asynchronous failure where the guard
498
+ * exists to give a synchronous one. The guard passes exactly what the bridge can
499
+ * deliver. */
500
+ export function hasTelegramPresence(record: PeerRecord): boolean {
501
+ const tg = record.interfaces?.telegram
502
+ if (!tg || typeof tg !== 'object' || Array.isArray(tg)) return false
503
+ const t = tg as Record<string, unknown>
504
+ return typeof t.bot === 'string' && t.bot.trim() !== ''
505
+ }
506
+
507
+ /** Sender policy for the telegram domain: a peer with NO declared telegram presence
508
+ * must not write into it — the recipient (a human in Telegram) would face a message
509
+ * from a peer that has no telegram face (identity confusion: who writes, by what
510
+ * right?). Bot channel = declared presence; no presence — no right to the domain.
511
+ * Enforced at the ROUTER, not the bridge: the refusal is synchronous and lands in
512
+ * the sender's tool result; a bridge-side check would fire AFTER the router's
513
+ * ok:true — a false-OK + silent loss, the class this contract forbids. Applied to
514
+ * the INTENDED channel (override / target default — before any wake side-effect,
515
+ * since a wake delivers the envelope as the boot first-message) AND to the RESOLVED
516
+ * channel at both delivery points (the only-live-session fallback can land on
517
+ * telegram without either). */
518
+ function telegramSenderGuard(
519
+ caller: ResolvedCaller,
520
+ targetRuntime: string,
521
+ targetPersonality: string,
522
+ ): Result<void> {
523
+ if (targetRuntime !== 'telegram') return ok(undefined)
524
+ if (hasTelegramPresence(caller.record)) return ok(undefined)
525
+ return err(
526
+ `telegram policy: sender "${caller.personality}" has no telegram face (no interfaces.telegram.bot in its passport) — message to "${targetPersonality}" NOT delivered; route via a telegram-faced peer (e.g. boris) or provision a bot for this sender`,
527
+ )
528
+ }
529
+
490
530
  /**
491
531
  * Route a send from a request-resolved caller to a target peer and deliver it.
492
532
  * hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
@@ -529,6 +569,12 @@ export async function routeSend(
529
569
  return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
530
570
  }
531
571
 
572
+ // Telegram sender policy — INTENDED channel (explicit override, or the target's
573
+ // default). Checked BEFORE any wake side-effect: a wake delivers the envelope as
574
+ // the boot first-message, past the resolved-target guards further down.
575
+ const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
576
+ if (!intendedGuard.ok) return intendedGuard
577
+
532
578
  // Built once — it is both the live-delivery payload and, on a miss, the wake
533
579
  // first-message (the woken session receives it as its boot task).
534
580
  const envelope = buildEnvelope({
@@ -554,6 +600,10 @@ export async function routeSend(
554
600
  const target = resolvePeerDeliveryTarget(personality, runtime, peer)
555
601
  if (target.ok) {
556
602
  if (target.value.address === caller.address) return err('cannot send to self')
603
+ // Telegram sender policy — RESOLVED channel (the only-live-session fallback can
604
+ // land on telegram without an override and without telegram being the default).
605
+ const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
606
+ if (!liveGuard.ok) return liveGuard
557
607
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
558
608
  const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
559
609
  if (!delivered.ok) return delivered
@@ -581,6 +631,11 @@ export async function routeSend(
581
631
  return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
582
632
  }
583
633
  if (live.value.address === caller.address) return err('cannot send to self')
634
+ // Telegram sender policy — post-wake RESOLVED channel (airtight: a wake should
635
+ // never bring up a telegram session — they are launchd-held — but the policy is
636
+ // enforced on every delivery point, not on an assumption about the wake).
637
+ const postWakeGuard = telegramSenderGuard(caller, live.value.runtime, personality)
638
+ if (!postWakeGuard.ok) return postWakeGuard
584
639
  // taskDelivered:false — the wake took the idempotent live-session fast path (a
585
640
  // CONCURRENT sender's boot won the wake.lock and delivered only ITS envelope as
586
641
  // the boot first-message). OUR envelope was not delivered by anything yet —
@@ -17,7 +17,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { spawnSync } from 'child_process'
20
- import { routeSend, type WakeFn } from './index.ts'
20
+ import { hasTelegramPresence, routeSend, type WakeFn } from './index.ts'
21
21
  import type { ResolvedCaller } from '../identity/index.ts'
22
22
  import type { PeerRecord } from '../registry/index.ts'
23
23
 
@@ -25,6 +25,13 @@ let root: string
25
25
  let sockDir: string
26
26
  const prevRoot = process.env.IAPEER_ROOT
27
27
 
28
+ // These suites spawn a REAL tmux session (the live-delivery path). Skip where
29
+ // tmux is absent — a clean CI runner has none, and an unguarded live-tmux test
30
+ // is what kept main's CI red (fail: "tmux new-session failed: null"). The
31
+ // foundation's preversion gate runs the full suite locally (this machine has
32
+ // tmux), so the release path keeps the live coverage; CI gates the rest.
33
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
34
+
28
35
  // Target peer: NON-TUI runtime (telegram) → the C-j delivery path lands into a
29
36
  // plain `cat` pane with no TUI prompt machinery; capture-pane then shows the
30
37
  // envelope text verbatim.
@@ -41,6 +48,10 @@ const callerRecord = {
41
48
  description: '',
42
49
  intelligence: 'artificial',
43
50
  cwd: '/tmp/boris',
51
+ // Telegram sender policy: the target of these suites is a telegram peer, so the
52
+ // caller must declare a telegram presence (the bot machine-key) — these suites
53
+ // double as the "bot-faced sender delivers as before" half of the policy criterion.
54
+ interfaces: { telegram: { bot: 'boris' } },
44
55
  } as unknown as PeerRecord
45
56
  const caller: ResolvedCaller = {
46
57
  personality: 'boris',
@@ -91,7 +102,7 @@ afterAll(() => {
91
102
  rmSync(root, { recursive: true, force: true })
92
103
  })
93
104
 
94
- describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
105
+ describe.if(tmuxAvailable)('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
95
106
  test('taskDelivered:false → routeSend delivers the envelope itself via the live path', async () => {
96
107
  const marker = 'SECOND-SENDER-ENVELOPE-7741'
97
108
  // The loser-of-the-lock outcome: the session is up (the WINNING wake booted it),
@@ -145,7 +156,7 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
145
156
  // one it woke with (defect 11.06: stale wake-time marker → false fresh).
146
157
  // ─────────────────────────────────────────────────────────────────────────────
147
158
 
148
- describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
159
+ describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)', () => {
149
160
  test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
150
161
  startTargetSession()
151
162
  const notes: Array<[string, string]> = []
@@ -206,3 +217,89 @@ describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
206
217
  killTargetSession()
207
218
  })
208
219
  })
220
+
221
+ // ─── Telegram sender policy (defect track 11.06) ─────────────────────────────
222
+ // A sender with NO declared telegram presence (interfaces.telegram.bot|user_id)
223
+ // must get a SYNCHRONOUS refusal when the delivery channel is telegram — the
224
+ // incident class: the bridge silently dropped such envelopes while the router
225
+ // reported ok:true. The bot-faced "delivers as before" half of the criterion is
226
+ // carried by the suites above (their caller declares interfaces.telegram.bot).
227
+
228
+ const facelessCaller: ResolvedCaller = {
229
+ personality: 'ghost',
230
+ runtime: 'claude',
231
+ address: 'claude-ghost',
232
+ description: '',
233
+ intelligence: 'artificial',
234
+ cwd: '/tmp/ghost',
235
+ record: {
236
+ personality: 'ghost',
237
+ runtime: 'claude',
238
+ runtimes: ['claude'],
239
+ description: '',
240
+ intelligence: 'artificial',
241
+ cwd: '/tmp/ghost',
242
+ } as unknown as PeerRecord,
243
+ }
244
+
245
+ describe('telegram sender policy — hasTelegramPresence', () => {
246
+ const rec = (tg: unknown): PeerRecord =>
247
+ ({ personality: 'x', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/x', ...(tg !== undefined ? { interfaces: { telegram: tg } } : {}) }) as unknown as PeerRecord
248
+ test('bot machine-key (agent peers) → sender face present', () => {
249
+ expect(hasTelegramPresence(rec({ bot: 'boris' }))).toBe(true)
250
+ })
251
+ test('user_id alone (natural peers — arthur) → NO sender face: the bridge sends FROM the bot key; user_id is a receive/operator identity (owner fact, bot-only predicate)', () => {
252
+ expect(hasTelegramPresence(rec({ user_id: '409502965' }))).toBe(false)
253
+ expect(hasTelegramPresence(rec({ user_id: 409502965 }))).toBe(false)
254
+ })
255
+ test('no interfaces / no telegram / empty bot → absent', () => {
256
+ expect(hasTelegramPresence(rec(undefined))).toBe(false)
257
+ expect(hasTelegramPresence(rec({}))).toBe(false)
258
+ expect(hasTelegramPresence(rec({ bot: '' }))).toBe(false)
259
+ expect(hasTelegramPresence(rec({ bot: ' ' }))).toBe(false)
260
+ expect(hasTelegramPresence(rec({ aliases: { '/alias-new': 'x' } }))).toBe(false)
261
+ })
262
+ })
263
+
264
+ describe('telegram sender policy — routeSend refusal', () => {
265
+ test('INTENDED channel (target default = telegram, peer offline): refused BEFORE the wake — wake never invoked', async () => {
266
+ let wakeCalled = false
267
+ const wake: WakeFn = async () => {
268
+ wakeCalled = true
269
+ return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
270
+ }
271
+ const r = await routeSend(facelessCaller, { personality: TARGET, message: 'm' }, { wake })
272
+ expect(r.ok).toBe(false)
273
+ if (!r.ok) {
274
+ expect(r.error.message).toContain('telegram policy')
275
+ expect(r.error.message).toContain('"ghost"')
276
+ expect(r.error.message).toContain('NOT delivered')
277
+ }
278
+ expect(wakeCalled).toBe(false)
279
+ })
280
+
281
+ test('explicit runtime=telegram override: refused with the policy line, not "offline"', async () => {
282
+ const r = await routeSend(facelessCaller, { personality: TARGET, runtime: 'telegram', message: 'm' })
283
+ expect(r.ok).toBe(false)
284
+ if (!r.ok) expect(r.error.message).toContain('telegram policy')
285
+ })
286
+ })
287
+
288
+ describe.if(tmuxAvailable)('telegram sender policy — live telegram session', () => {
289
+ test('faceless sender + LIVE telegram target → refused, envelope NEVER reaches the pane', async () => {
290
+ startTargetSession()
291
+ const r = await routeSend(facelessCaller, { personality: TARGET, message: 'POLICY-MUST-BLOCK' })
292
+ expect(r.ok).toBe(false)
293
+ if (!r.ok) expect(r.error.message).toContain('telegram policy')
294
+ expect(capturePane()).not.toContain('POLICY-MUST-BLOCK')
295
+ killTargetSession()
296
+ })
297
+
298
+ test('bot-faced sender (boris) + LIVE telegram target → delivered as before', async () => {
299
+ startTargetSession()
300
+ const r = await routeSend(caller, { personality: TARGET, message: 'BOT-FACED-OK' })
301
+ expect(r.ok).toBe(true)
302
+ expect(capturePane()).toContain('BOT-FACED-OK')
303
+ killTargetSession()
304
+ })
305
+ })