@agfpd/iapeer 0.2.32 → 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.32",
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
+ }
@@ -117,7 +117,7 @@ export function canaryScript(o: CanaryScriptOptions): string {
117
117
  `{`,
118
118
  ` echo "server-death identity=${o.identity} ts=$ts server_pid=$SPID wait_rc=$rc cause=$cause"`,
119
119
  ` echo '--- vm_stat'; vm_stat`,
120
- ` echo '--- swapusage'; sysctl -n vm.swapusage`,
120
+ ` echo '--- swapusage'; /usr/sbin/sysctl -n vm.swapusage`,
121
121
  ` echo '--- ps-top-rss'; ps axo pid,ppid,rss,etime,command | sort -rn -k3 | head -25`,
122
122
  ` echo '--- diagnosticreports-user'; ls -t "$HOME/Library/Logs/DiagnosticReports" 2>/dev/null | head`,
123
123
  ` echo '--- diagnosticreports-system'; ls -t /Library/Logs/DiagnosticReports 2>/dev/null | head`,