@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 +1 -1
- package/src/cli/cli.test.ts +23 -0
- package/src/cli/index.ts +85 -6
- package/src/daemon/index.ts +12 -1
- package/src/daemon/main.test.ts +63 -0
- package/src/daemon/main.ts +36 -0
- package/src/enable/enable.test.ts +15 -12
- package/src/enable/index.ts +21 -13
- package/src/identity/index.ts +9 -1
- package/src/identity/profileStandard.test.ts +116 -0
- package/src/identity/profileStandard.ts +218 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +49 -16
- package/src/launch/composeSystemPrompt.test.ts +80 -33
- package/src/launch/composeSystemPrompt.ts +99 -61
- package/src/launch/types.ts +13 -2
- package/src/lifecycle/index.ts +9 -3
- package/src/registry/index.ts +9 -6
- package/src/transport/index.ts +25 -0
- package/src/transport/routesend-wake.test.ts +69 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
889
|
-
|
|
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
|
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -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', () => {
|
package/src/daemon/main.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/daemon/main.ts
CHANGED
|
@@ -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`
|
|
48
|
-
const CODEX_LIST =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
peer
|
|
53
|
-
|
|
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
|
|
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') //
|
|
59
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled')
|
|
60
|
-
expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in
|
|
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
|
|
package/src/enable/index.ts
CHANGED
|
@@ -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
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|
package/src/identity/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|