@agfpd/iapeer 0.2.33 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.33",
3
+ "version": "0.2.35",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -145,6 +145,29 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
145
145
  expect(isStopped(cfg, 'claude-parked')).toBe(false)
146
146
  expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
147
147
  })
148
+ test('start --all clears the flag of EVERY registered peer; unregistered garbage untouched (incident 11.06)', async () => {
149
+ await register('p1')
150
+ await register('p2')
151
+ const e = env()
152
+ const cfg = loadLifecycleConfig(e)
153
+ // a stopped fleet …
154
+ setStopped(cfg, 'claude-p1')
155
+ setStopped(cfg, 'claude-p2')
156
+ // … plus a stray stop-flag for an identity NO registry record claims
157
+ setStopped(cfg, 'claude-ghost')
158
+ expect(await runCli(['start', '--all'], e)).toBe(0)
159
+ expect(isStopped(cfg, 'claude-p1')).toBe(false)
160
+ expect(isStopped(cfg, 'claude-p2')).toBe(false)
161
+ expect(isStopped(cfg, 'claude-ghost')).toBe(true) // unregistered → never enumerated, never touched
162
+ })
163
+ test('start without a peer and without --all → usage, nothing executed', async () => {
164
+ await register('p3')
165
+ const e = env()
166
+ const cfg = loadLifecycleConfig(e)
167
+ setStopped(cfg, 'claude-p3')
168
+ expect(await runCli(['start'], e)).toBe(2)
169
+ expect(isStopped(cfg, 'claude-p3')).toBe(true) // flag untouched — usage only
170
+ })
148
171
  })
149
172
 
150
173
  describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
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'
@@ -438,7 +445,7 @@ export async function sendMessage(
438
445
  // unawaited in-process wake would die with it; the daemon's supervise-tick
439
446
  // drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
440
447
  // kicks, not a new mechanism.
441
- const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
448
+ const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps, makeNoteLiveTopic } = await import('../daemon/main.ts')
442
449
  const cfg = loadLifecycleConfig(env)
443
450
  const t0 = Date.now()
444
451
  const result = await routeSend(
@@ -450,7 +457,10 @@ export async function sendMessage(
450
457
  topic: opts.topic,
451
458
  attachments: opts.attachments,
452
459
  },
453
- { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}) },
460
+ // noteLiveTopic — CLI-path parity (same seam the daemon wires): a live delivery
461
+ // through the CLI fallback must update the target's .topic marker too, or a
462
+ // daemon-restart-window send re-opens the stale-marker false-fresh (defect 11.06).
463
+ { wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}), noteLiveTopic: makeNoteLiveTopic(cfg, env) },
454
464
  )
455
465
  // delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
456
466
  // routed through the CLI left to=<peer> at ZERO for the day while real wakes
@@ -544,8 +554,11 @@ const USAGE = `usage: iapeer <verb> [args]
544
554
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
545
555
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
546
556
  list [--json] registered peers + per-runtime liveness
547
- stop <peer> [runtime] | --all durable-stop a warm peer / bootout an always-on one
548
- start <peer> [runtime] re-enable a stopped peer / bootstrap an always-on one
557
+ verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index)
558
+ stop <peer> [runtime] | --all warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it
559
+ until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
560
+ start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
561
+ it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer
549
562
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
550
563
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
551
564
  <runtime> launch the cwd's peer (ALWAYS fresh)
@@ -871,6 +884,61 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
871
884
  out(flags.json ? JSON.stringify(rows, null, 2) + '\n' : formatListTable(rows))
872
885
  return 0
873
886
  }
887
+ case 'verify': {
888
+ // 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.
891
+ const index = readPeersIndex({ env })
892
+ let errors = 0
893
+ let driftCount = 0
894
+ const lines: string[] = []
895
+ for (const peer of index.peers) {
896
+ const path = peerProfilePath(peer.cwd)
897
+ if (!existsSync(path)) {
898
+ errors++
899
+ lines.push(`✗ ${peer.personality}: no local profile at ${path}`)
900
+ continue
901
+ }
902
+ let raw: unknown
903
+ try {
904
+ raw = JSON.parse(readFileSync(path, 'utf8'))
905
+ } catch (e) {
906
+ errors++
907
+ lines.push(`✗ ${peer.personality}: profile is invalid JSON — ${e instanceof Error ? e.message : String(e)}`)
908
+ continue
909
+ }
910
+ const issues = validateProfileStandard(raw, peer.cwd)
911
+ const errs = issues.filter(i => i.severity === 'error')
912
+ const warns = issues.filter(i => i.severity === 'warn')
913
+ if (errs.length > 0) errors += errs.length
914
+ if (!isConformant(issues)) {
915
+ lines.push(`✗ ${peer.personality}: ${errs.map(i => `${i.field} — ${i.message}`).join('; ')}`)
916
+ } else if (warns.length > 0 && flags.json !== true) {
917
+ lines.push(`⚠ ${peer.personality}: ${warns.map(i => i.field).join(', ')}`)
918
+ }
919
+ }
920
+ const reconcile = reconcileIndex({ env })
921
+ for (const r of reconcile) {
922
+ if (r.drift === null) {
923
+ // missing local profile already reported above as an error
924
+ } else if (r.drift.length > 0) {
925
+ driftCount++
926
+ lines.push(`↯ ${r.personality}: index↔local drift on ${r.drift.join(', ')}`)
927
+ }
928
+ }
929
+ if (flags.json === true) {
930
+ out(JSON.stringify({ peers: index.peers.length, errors, drift: driftCount, reconcile }, null, 2) + '\n')
931
+ } else {
932
+ for (const l of lines) out(l + '\n')
933
+ out(`\n${index.peers.length} peers · ${errors} error(s) · ${driftCount} index↔local drift(s)\n`)
934
+ }
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`)
939
+ }
940
+ return errors > 0 || (driftCount > 0 && flags.fix !== true) ? 1 : 0
941
+ }
874
942
  case 'stop': {
875
943
  // --all stops every registered peer (the fleet guard still refuses foreign
876
944
  // persistent-peer plists, so the live fleet stays untouched).
@@ -885,8 +953,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
885
953
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
886
954
  }
887
955
  case 'start': {
888
- if (!positionals[0]) return usage(errOut)
889
- const outcomes = startPeer(positionals[0], positionals[1], { env })
956
+ // --all re-enables every REGISTERED peer (mirror of `stop --all` — the 11.06
957
+ // incident: bringing a stopped fleet back required a manual loop over ~25
958
+ // peers). Enumeration is the registry, so unregistered garbage (stray flag
959
+ // files for identities no record claims) is never touched. NOTE: for warm
960
+ // peers this clears the stop flag only — no session is launched; each peer
961
+ // becomes wakeable and the daemon brings its session up on the first message.
962
+ const peers = flags.all === true
963
+ ? readPeersIndex({ env }).peers.map(p => p.personality)
964
+ : positionals[0]
965
+ ? [positionals[0]]
966
+ : null
967
+ if (!peers) return usage(errOut)
968
+ const outcomes = peers.flatMap(p => startPeer(p, flags.all === true ? undefined : positionals[1], { env }))
890
969
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
891
970
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
892
971
  }
@@ -188,6 +188,10 @@ export interface CallToolDeps {
188
188
  onDelivered?: (caller: ResolvedCaller) => void
189
189
  /** Ephemeral-target serial-queue delivery (M3) — see transport.EphemeralRouteDeps. */
190
190
  ephemeral?: EphemeralRouteDeps
191
+ /** LIVE-delivered topic → the target's `.topic` marker (fresh-vs-resume seam) —
192
+ * see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
193
+ * lifecycle; the production main wires makeNoteLiveTopic. */
194
+ noteLiveTopic?: (identity: string, topic: string) => void
191
195
  }
192
196
 
193
197
  export async function callTool(
@@ -205,7 +209,7 @@ export async function callTool(
205
209
  attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
206
210
  }
207
211
  const t0 = Date.now()
208
- const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral })
212
+ const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral, noteLiveTopic: deps.noteLiveTopic })
209
213
  // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
210
214
  // to lifecycle.log) — metadata only, never the body. Both branches of the
211
215
  // routeSend result are recorded, so a suspected loss is reconstructable from
@@ -367,6 +371,12 @@ export interface StartDaemonOptions extends StorageOptions {
367
371
  * lifecycle queue + drain (makeEphemeralRouteDeps).
368
372
  */
369
373
  ephemeral?: EphemeralRouteDeps
374
+ /**
375
+ * LIVE-delivered topic → the target identity's `.topic` marker (fresh-vs-resume
376
+ * seam) — see transport.RouteDeps.noteLiveTopic. OFF by default; the production
377
+ * main wires the lifecycle marker write (makeNoteLiveTopic).
378
+ */
379
+ noteLiveTopic?: (identity: string, topic: string) => void
370
380
  /**
371
381
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
372
382
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -399,6 +409,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
399
409
  deliveryLogDir: opts.deliveryLogDir,
400
410
  onDelivered: opts.onDelivered,
401
411
  ephemeral: opts.ephemeral,
412
+ noteLiveTopic: opts.noteLiveTopic,
402
413
  })
403
414
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
404
415
  res.on('close', () => {
@@ -15,6 +15,7 @@ import {
15
15
  installDaemonPlist,
16
16
  makeArmEphemeralOnDelivered,
17
17
  makeEphemeralRouteDeps,
18
+ makeNoteLiveTopic,
18
19
  startConfiguredDaemon,
19
20
  DEFAULT_DAEMON_PORT,
20
21
  } from './main.ts'
@@ -23,6 +24,10 @@ import {
23
24
  ephemeralQueueDepth,
24
25
  hasEphemeralArmed,
25
26
  peekEphemeralTask,
27
+ readTopic,
28
+ resolveWakeMode,
29
+ setIdleReaped,
30
+ writeTopic,
26
31
  type LifecycleConfig,
27
32
  } from '../lifecycle/index.ts'
28
33
  import type { ResolvedCaller } from '../identity/index.ts'
@@ -304,3 +309,61 @@ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
304
309
  expect(r.ok).toBe(false) // 'codex' is not declared for this worker
305
310
  })
306
311
  })
312
+
313
+ // ─────────────────────────────────────────────────────────────────────────────
314
+ // makeNoteLiveTopic — the live-delivered-topic composition (fresh-vs-resume seam):
315
+ // the .topic marker must track the topic the session last WORKED ON, not the one
316
+ // it woke with (defect 11.06: a day-stale wake-time marker false-freshed the
317
+ // stop→start resume of a long-lived executor).
318
+ // ─────────────────────────────────────────────────────────────────────────────
319
+
320
+ describe('makeNoteLiveTopic (live-delivered topic → .topic marker)', () => {
321
+ function noteCfg(): LifecycleConfig {
322
+ const root = mkTmp()
323
+ return { stateDir: join(root, 'state'), eventLogDir: join(root, 'logs') } as LifecycleConfig
324
+ }
325
+
326
+ test('topic SHIFT → marker updated + ev=topic-note logged; same topic → no churn, no log spam', () => {
327
+ const cfg = noteCfg()
328
+ const note = makeNoteLiveTopic(cfg, {} as NodeJS.ProcessEnv)
329
+ writeTopic(cfg, 'claude-x', 'topic-a') // the wake established topic A
330
+ note('claude-x', 'topic-b') // a live delivery switches the work to B
331
+ expect(readTopic(cfg, 'claude-x')).toBe('topic-b')
332
+ const logPath = join(cfg.eventLogDir, 'lifecycle.log')
333
+ const log = readFileSync(logPath, 'utf8')
334
+ expect(log).toContain('ev=topic-note')
335
+ expect(log).toContain('identity=claude-x')
336
+ expect(log).toContain('topic=topic-b')
337
+ // the common case — every further message continues topic B → strictly nothing
338
+ note('claude-x', 'topic-b')
339
+ note('claude-x', 'topic-b')
340
+ expect(readFileSync(logPath, 'utf8').match(/ev=topic-note/g)?.length).toBe(1)
341
+ })
342
+
343
+ test('acceptance scenario (boris 11.06): woke with topic A → worked live-delivered topic B → clean stop-park → wake with topic B = RESUME, not fresh', () => {
344
+ const cfg = noteCfg()
345
+ const id = 'claude-x'
346
+ // executor peer cwd: no telegram interface (not human-conversational), no wake_policy
347
+ const cwd = mkTmp()
348
+ mkdirSync(join(cwd, '.iapeer'), { recursive: true })
349
+ writeFileSync(join(cwd, '.iapeer', 'peer-profile.json'), JSON.stringify({ personality: 'x', runtime: 'claude' }))
350
+ writeTopic(cfg, id, 'topic-a') // session woke with topic A (the stale pre-fix marker)
351
+ makeNoteLiveTopic(cfg)(id, 'topic-b') // ...then took topic-B messages in the LIVE session all day
352
+ setIdleReaped(cfg, id) // deliberate stop = clean park (stopPeer's promise)
353
+ const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
354
+ // pre-fix this read the stale 'topic-a' → cause=idle-reaped-new-topic → FRESH (context lost)
355
+ expect(resolveWakeMode(cfg, id, cwd, undefined, hasTranscript, 'topic-b')).toEqual({
356
+ resume: true,
357
+ resumeRef: 'uuid-1',
358
+ cause: 'idle-reaped-resume',
359
+ })
360
+ })
361
+
362
+ test('marker/log failure → no-op, never throws (a delivered message must stand)', () => {
363
+ const root = mkTmp()
364
+ const blocker = join(root, 'state-is-a-file')
365
+ writeFileSync(blocker, 'not a dir')
366
+ const cfg = { stateDir: join(blocker, 'state'), eventLogDir: join(blocker, 'logs') } as LifecycleConfig // mkdir must fail
367
+ expect(() => makeNoteLiveTopic(cfg)('claude-x', 'topic-b')).not.toThrow()
368
+ })
369
+ })
@@ -35,12 +35,15 @@ import {
35
35
  isEphemeralPeer,
36
36
  loadLifecycleConfig,
37
37
  processEagerRelaunches,
38
+ readTopic,
38
39
  resolveWakeRuntime,
39
40
  setEphemeralArmed,
40
41
  superviseTick,
41
42
  wakeOrSpawn,
43
+ writeTopic,
42
44
  type LifecycleConfig,
43
45
  } from '../lifecycle/index.ts'
46
+ import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
44
47
  import { buildProcessAddress } from '../core/socket.ts'
45
48
  import { err, ok } from '../core/errors.ts'
46
49
  import type { ResolvedCaller } from '../identity/index.ts'
@@ -98,6 +101,35 @@ export function makeArmEphemeralOnDelivered(cfg: LifecycleConfig): (caller: Reso
98
101
  }
99
102
  }
100
103
 
104
+ /**
105
+ * The fourth lifecycle⇆transport seam (after makeWakeFn / makeArmEphemeralOnDelivered /
106
+ * makeEphemeralRouteDeps): record a LIVE-delivered topic as the target identity's
107
+ * `.topic` marker, so the marker tracks the topic the session LAST WORKED ON — the
108
+ * semantics resolveWakeMode's executor discriminator actually wants — not merely the
109
+ * topic it woke with. Live defect 11.06 (boris): an executor woke topicless at 13:13,
110
+ * worked a delivered topic all day, was stop-parked clean, and the topic-matching
111
+ * resume-wake at 17:06 compared against the STALE wake-time marker → cause=
112
+ * idle-reaped-new-topic → FRESH; the day's context was lost despite the clean park.
113
+ * Same-topic deliveries are a no-op (no marker churn, no log spam) — the lifecycle.log
114
+ * line `ev=topic-note` therefore marks exactly the moments the session's working
115
+ * topic SHIFTED, which is what a fresh-vs-resume postmortem needs. Best-effort: a
116
+ * marker/log failure never fails the already-delivered message.
117
+ */
118
+ export function makeNoteLiveTopic(
119
+ cfg: LifecycleConfig,
120
+ env: NodeJS.ProcessEnv = process.env,
121
+ ): (identity: string, topic: string) => void {
122
+ return (identity, topic) => {
123
+ try {
124
+ if (readTopic(cfg, identity) === topic) return
125
+ writeTopic(cfg, identity, topic)
126
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'topic-note', identity, topic }, { env })
127
+ } catch {
128
+ /* the marker is a discriminator hint — never fail a delivered message */
129
+ }
130
+ }
131
+ }
132
+
101
133
  /**
102
134
  * wake_policy:"ephemeral" M3 — the serial-queue delivery composition (the third
103
135
  * lifecycle⇆transport seam after makeWakeFn / makeArmEphemeralOnDelivered).
@@ -178,6 +210,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
178
210
  // M3 serial queue (see makeEphemeralRouteDeps): ephemeral TARGET ⇒ enqueue +
179
211
  // async drain. No-op for every other target.
180
212
  ephemeral: makeEphemeralRouteDeps(cfg, env),
213
+ // Live-delivered topic → the target's .topic marker (see makeNoteLiveTopic):
214
+ // the wake resolver's executor discriminator then compares against the topic
215
+ // the session last WORKED ON, not the one it woke with (defect 11.06).
216
+ noteLiveTopic: makeNoteLiveTopic(cfg, env),
181
217
  supervise: {
182
218
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
183
219
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
@@ -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
+ })