@agfpd/iapeer 0.2.36 → 0.2.38

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.36",
3
+ "version": "0.2.38",
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)
@@ -6,12 +6,13 @@
6
6
  // error (fail-open). The core never acts on heartbeat staleness — it only REPORTS
7
7
  // it (healing the provider's daemon is the provider's job, their ADR-010).
8
8
 
9
- import { readFileSync, statSync } from 'fs'
9
+ import { closeSync, openSync, readFileSync, statSync } from 'fs'
10
10
  import { join } from 'path'
11
11
  import { IAPEER_VERSION } from '../core/version.ts'
12
12
  import { resolveGlobalRoot } from '../storage/index.ts'
13
13
  import { daemonDiscoveryPath } from '../daemon/index.ts'
14
14
  import { waitForDaemonHealthy } from '../update/index.ts'
15
+ import { iapeerBinPath } from '../install/index.ts'
15
16
 
16
17
  /** The slot-declaration filename in the storage root (next to peers-profiles.json). */
17
18
  export const MEMORY_PROVIDER_FILE = 'memory-provider.json'
@@ -135,16 +136,48 @@ export function heartbeatAgeSecs(provider: MemoryProvider, nowMs: number = Date.
135
136
  }
136
137
  }
137
138
 
139
+ /**
140
+ * Probe whether the iapeer binary holds Full Disk Access (macOS TCC). Reads the
141
+ * user TCC.db — a canonical FDA-gated resource that is NEVER promptable (silent
142
+ * EPERM without the grant), so THE PROBE ITSELF raises no dialog (it must not be
143
+ * the very noise it detects). Byte-free: open+close to prove access, no content
144
+ * read. Returns:
145
+ * true — readable → FDA granted
146
+ * false — EPERM/EACCES → FDA NOT granted (peers will hit TCC prompts on
147
+ * protected folders + foreign app containers — see onboard contract)
148
+ * null — not macOS / no HOME / TCC.db absent → cannot determine (no claim)
149
+ */
150
+ export function probeFullDiskAccess(env: NodeJS.ProcessEnv = process.env): boolean | null {
151
+ if (process.platform !== 'darwin') return null
152
+ const home = env.HOME
153
+ if (!home) return null
154
+ const marker = join(home, 'Library', 'Application Support', 'com.apple.TCC', 'TCC.db')
155
+ try {
156
+ closeSync(openSync(marker, 'r'))
157
+ return true
158
+ } catch (e) {
159
+ const code = (e as NodeJS.ErrnoException).code
160
+ if (code === 'EPERM' || code === 'EACCES') return false
161
+ return null // ENOENT / unexpected → undeterminable, never a false claim
162
+ }
163
+ }
164
+
138
165
  export interface HostStatus {
139
166
  version: string
140
167
  daemon: { healthy: boolean; url: string | null; sock: string | null }
141
168
  memory: { provider: MemoryProvider | null; heartbeatAgeSecs: number | null }
169
+ /** Full Disk Access of the iapeer binary: true granted / false not-granted /
170
+ * null undeterminable (non-macOS or TCC.db unreadable for a non-TCC reason). */
171
+ fda: boolean | null
142
172
  }
143
173
 
144
174
  export interface HostStatusOptions {
145
175
  env?: NodeJS.ProcessEnv
146
176
  /** Injectable daemon probe (tests). Default: the real socket probe. */
147
177
  probe?: () => Promise<boolean>
178
+ /** Injectable FDA probe (tests — the default reads the real TCC.db, which a
179
+ * hermetic test must not touch). Default: probeFullDiskAccess(env). */
180
+ fdaProbe?: () => boolean | null
148
181
  }
149
182
 
150
183
  /** Assemble the host snapshot: baked version + daemon probe + memory slot. */
@@ -161,10 +194,12 @@ export async function hostStatus(opts: HostStatusOptions = {}): Promise<HostStat
161
194
  }
162
195
  const health = await waitForDaemonHealthy({ env, timeoutMs: 2500, needConsecutive: 1, probe: opts.probe })
163
196
  const provider = readMemoryProvider(env)
197
+ const fda = (opts.fdaProbe ?? (() => probeFullDiskAccess(env)))()
164
198
  return {
165
199
  version: IAPEER_VERSION,
166
200
  daemon: { healthy: health.healthy, url, sock },
167
201
  memory: { provider, heartbeatAgeSecs: provider ? heartbeatAgeSecs(provider) : null },
202
+ fda,
168
203
  }
169
204
  }
170
205
 
@@ -184,5 +219,19 @@ export function formatHostStatus(s: HostStatus): string {
184
219
  : ' (daemon not running — no heartbeat file)'
185
220
  }
186
221
  const memory = s.memory.provider ? `${s.memory.provider.provider} ${s.memory.provider.version}${hb}` : 'none'
187
- return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n`
222
+ // FDA line: granted → terse OK; NOT granted → the actionable hint (a fresh host
223
+ // without FDA silently meets TCC prompts on every new protected class — peers
224
+ // read protected folders + foreign app containers, attributed to this binary;
225
+ // see onboard contract §INSTALL). undeterminable → say so, never a false claim.
226
+ let fda: string
227
+ if (s.fda === true) {
228
+ fda = 'fda: granted'
229
+ } else if (s.fda === false) {
230
+ fda =
231
+ `fda: NOT granted — peers will hit TCC prompts on protected paths.\n` +
232
+ ` grant: System Settings → Privacy & Security → Full Disk Access → + → ⇧⌘G → ${iapeerBinPath()}`
233
+ } else {
234
+ fda = 'fda: unknown (not macOS / undeterminable)'
235
+ }
236
+ return `iapeer ${s.version}\ndaemon: ${daemon}\nmemory: ${memory}\n${fda}\n`
188
237
  }
@@ -128,7 +128,7 @@ describe('heartbeatAgeSecs', () => {
128
128
  describe('hostStatus + formatHostStatus', () => {
129
129
  test('empty slot → memory: none; daemon health from the injected probe', async () => {
130
130
  const root = mkTmp()
131
- const s = await hostStatus({ env: envFor(root), probe: async () => true })
131
+ const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => null })
132
132
  expect(s.daemon.healthy).toBe(true)
133
133
  expect(s.memory.provider).toBeNull()
134
134
  expect(formatHostStatus(s)).toContain('memory: none')
@@ -140,7 +140,7 @@ describe('hostStatus + formatHostStatus', () => {
140
140
  const hb = join(root, 'memoryd.heartbeat')
141
141
  writeFileSync(hb, '')
142
142
  writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, heartbeat: hb }))
143
- const s = await hostStatus({ env, probe: async () => true })
143
+ const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
144
144
  expect(formatHostStatus(s)).toMatch(/memory: iapeer-memory 0\.1\.0 \(heartbeat \d+s ago\)/)
145
145
  })
146
146
 
@@ -152,6 +152,7 @@ describe('hostStatus + formatHostStatus', () => {
152
152
  provider: { ...VALID, heartbeat: '/nope/hb' } as MemoryProvider,
153
153
  heartbeatAgeSecs: null,
154
154
  },
155
+ fda: null,
155
156
  }
156
157
  const text = formatHostStatus(s)
157
158
  expect(text).toContain('memory: iapeer-memory 0.1.0 (daemon not running — no heartbeat file)')
@@ -164,9 +165,48 @@ describe('hostStatus + formatHostStatus', () => {
164
165
  const stateDir = join(root, 'state', 'iapeer')
165
166
  mkdirSync(stateDir, { recursive: true })
166
167
  writeFileSync(join(stateDir, 'router.json'), JSON.stringify({ sock: '/x/router.sock', tcp: 'http://127.0.0.1:8765/mcp' }))
167
- const s = await hostStatus({ env, probe: async () => true })
168
+ const s = await hostStatus({ env, probe: async () => true, fdaProbe: () => null })
168
169
  const text = formatHostStatus(s)
169
170
  expect(text).toContain('@ http://127.0.0.1:8765/mcp')
170
171
  expect(text).toContain('+ /x/router.sock')
171
172
  })
172
173
  })
174
+
175
+ describe('FDA detection (iapeer status — fresh-host observability)', () => {
176
+ function base(fda: boolean | null): HostStatus {
177
+ return {
178
+ version: '0.0.0',
179
+ daemon: { healthy: true, url: null, sock: null },
180
+ memory: { provider: null, heartbeatAgeSecs: null },
181
+ fda,
182
+ }
183
+ }
184
+
185
+ test('granted → terse OK line, no instruction', () => {
186
+ const text = formatHostStatus(base(true))
187
+ expect(text).toContain('fda: granted')
188
+ expect(text).not.toContain('NOT granted')
189
+ expect(text).not.toContain('Full Disk Access')
190
+ })
191
+
192
+ test('NOT granted → actionable hint with the binary path', () => {
193
+ const text = formatHostStatus(base(false))
194
+ expect(text).toContain('fda: NOT granted')
195
+ expect(text).toContain('Full Disk Access')
196
+ expect(text).toMatch(/iapeer$|iapeer\n/m) // the grant path ends at the binary
197
+ })
198
+
199
+ test('undeterminable → explicit unknown, never a false claim', () => {
200
+ const text = formatHostStatus(base(null))
201
+ expect(text).toContain('fda: unknown')
202
+ expect(text).not.toContain('NOT granted')
203
+ expect(text).not.toContain('granted\n') // not the bare "granted" either
204
+ })
205
+
206
+ test('hostStatus threads the injected fdaProbe verbatim', async () => {
207
+ const root = mkTmp()
208
+ const s = await hostStatus({ env: envFor(root), probe: async () => true, fdaProbe: () => false })
209
+ expect(s.fda).toBe(false)
210
+ expect(formatHostStatus(s)).toContain('fda: NOT granted')
211
+ })
212
+ })
@@ -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.
@@ -91,7 +98,7 @@ afterAll(() => {
91
98
  rmSync(root, { recursive: true, force: true })
92
99
  })
93
100
 
94
- describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
101
+ describe.if(tmuxAvailable)('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
95
102
  test('taskDelivered:false → routeSend delivers the envelope itself via the live path', async () => {
96
103
  const marker = 'SECOND-SENDER-ENVELOPE-7741'
97
104
  // The loser-of-the-lock outcome: the session is up (the WINNING wake booted it),
@@ -145,7 +152,7 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
145
152
  // one it woke with (defect 11.06: stale wake-time marker → false fresh).
146
153
  // ─────────────────────────────────────────────────────────────────────────────
147
154
 
148
- describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
155
+ describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)', () => {
149
156
  test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
150
157
  startTargetSession()
151
158
  const notes: Array<[string, string]> = []