@agfpd/iapeer 0.2.27 → 0.2.29

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.27",
3
+ "version": "0.2.29",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,60 @@ async function register(personality: string, runtime = 'claude', intelligence: '
40
40
  await upsertPeer({ personality, runtime, cwd: `/tmp/${personality}`, intelligence }, { rootDir: root })
41
41
  }
42
42
 
43
+ // ─── v1.2 provision-инверсия at the CLI joints (memory-plugin printer + remove) ───
44
+
45
+ describe('memory-plugin / remove with a v1.2 provision slot', () => {
46
+ function declareV12Slot(): string {
47
+ const journal = join(root, 'journal.txt')
48
+ const script = join(root, 'fake-provider.sh')
49
+ writeFileSync(script, `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`, { mode: 0o755 })
50
+ writeFileSync(
51
+ join(root, 'memory-provider.json'),
52
+ JSON.stringify({
53
+ provider: 'fake-mem',
54
+ package: '@x/fake',
55
+ version: '0.0.1',
56
+ registeredAt: 'x',
57
+ provision: { command: script, args: ['provision-peer', '--occasion', '{occasion}'] },
58
+ unprovision: { command: script, args: ['unprovision-peer', '--cwd', '{cwd}', '--occasion', '{occasion}'] },
59
+ }),
60
+ )
61
+ return journal
62
+ }
63
+
64
+ test('memory-plugin on --peer: printer survives plugin=null (v1.2 path), exit 0 (live-caught 11.06 smoke)', async () => {
65
+ declareV12Slot()
66
+ await register('alpha')
67
+ let captured = ''
68
+ const origWrite = process.stdout.write
69
+ process.stdout.write = ((s: string | Uint8Array) => {
70
+ captured += typeof s === 'string' ? s : Buffer.from(s).toString('utf8')
71
+ return true
72
+ }) as typeof process.stdout.write
73
+ try {
74
+ const code = await runCli(['memory-plugin', 'on', '--peer', 'alpha'], env())
75
+ expect(code).toBe(0)
76
+ expect(captured).toContain('provision: provider command')
77
+ expect(captured).toContain('alpha (claude): ok')
78
+ } finally {
79
+ process.stdout.write = origWrite
80
+ }
81
+ })
82
+
83
+ test('remove: unprovision runs with occasion=remove BEFORE the purge, outcome reported', async () => {
84
+ const journal = declareV12Slot()
85
+ await register('beta')
86
+ const o = await removePeerCli('beta', { env: env() })
87
+ expect(o.action).toBe('removed')
88
+ expect(o.unprovision).toEqual(['claude:ok'])
89
+ const { readFileSync } = await import('fs')
90
+ const j = readFileSync(journal, 'utf8')
91
+ expect(j).toContain('unprovision-peer')
92
+ expect(j).toContain('--cwd\n/tmp/beta')
93
+ expect(j).toContain('remove')
94
+ })
95
+ })
96
+
43
97
  describe('list', () => {
44
98
  test('lists registered peers with per-runtime liveness (asleep / stopped)', async () => {
45
99
  await register('alpha')
package/src/cli/index.ts CHANGED
@@ -48,6 +48,9 @@ import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
48
48
  import { runAlwaysOn } from '../launch/launchdRun.ts'
49
49
  import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
50
50
  import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
51
+ import { readMemoryProvider } from '../status/index.ts'
52
+ import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
53
+ import { pluginLogsDir } from '../storage/index.ts'
51
54
 
52
55
  // ─────────────────────────────────────────────────────────────────────────────
53
56
  // list — registry + per-runtime liveness (contract Примитивы §list)
@@ -274,6 +277,9 @@ export interface RemoveOutcome {
274
277
  * deliberately keeps the folder — user data is never deleted by a registry reap
275
278
  * (boris's finding 10.06: say so in the output instead of leaving silent orphans). */
276
279
  cwd?: string
280
+ /** v1.2: per-runtime unprovision outcomes (`<rt>:<state>`), present when the
281
+ * slot declares an unprovision command (occasion=remove ran before the purge). */
282
+ unprovision?: string[]
277
283
  /** Identity-keyed lifecycle artifacts purged with the record (state/lifecycle/
278
284
  * `<identity>.*` per runtime). Without this purge a NEWBORN peer reusing the
279
285
  * personality inherits the dead namesake's parking (live defect, boris 10.06:
@@ -309,13 +315,59 @@ export async function removePeerCli(
309
315
  }
310
316
  }
311
317
  await removePeer(personality, { env })
318
+ // v1.2 UNPROVISION joint (контракт §Provision провайдера): a provision-declaring
319
+ // slot gets its unprovision command per agentic runtime with occasion=remove —
320
+ // BEFORE purgeIdentityState, so the provider sees the peer's last consistent
321
+ // state while unwinding its surfaces. Best-effort: a provider hiccup must not
322
+ // block the reap (the outcome line says what happened; repair is the provider's
323
+ // verify sweep).
324
+ const unprovisionOutcomes: string[] = []
325
+ try {
326
+ const slot = readMemoryProvider(env)
327
+ if (slot?.unprovision) {
328
+ const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
329
+ const agentic = peer.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
330
+ for (const rt of agentic) {
331
+ const o = runProvisionCommand({
332
+ block: slot.unprovision,
333
+ cwd: peer.cwd,
334
+ runtime: rt,
335
+ personality,
336
+ occasion: 'remove',
337
+ env,
338
+ })
339
+ appendLifecycleEvent(
340
+ pluginLogsDir('iapeer', { env }),
341
+ {
342
+ ev: 'memory-provision',
343
+ identity: `${rt}-${personality}`,
344
+ occasion: 'remove',
345
+ state: o.state,
346
+ exit: o.exitCode ?? undefined,
347
+ ms: o.durationMs,
348
+ detail: o.detail,
349
+ },
350
+ { env },
351
+ )
352
+ unprovisionOutcomes.push(`${rt}:${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
353
+ }
354
+ }
355
+ } catch (e) {
356
+ unprovisionOutcomes.push(`failed: ${e instanceof Error ? e.message : String(e)}`)
357
+ }
312
358
  // Purge identity-keyed lifecycle state WITH the record (per runtime): stale
313
359
  // .stopped/.idle-reaped/... must never outlive the peer and ambush a future
314
360
  // namesake (purgeIdentityState doc). After the registry write, so a failed
315
361
  // remove never half-purges a still-registered peer.
316
362
  const cfg = loadLifecycleConfig(env)
317
363
  const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
318
- return { personality, action: 'removed', cwd: peer.cwd, purgedState }
364
+ return {
365
+ personality,
366
+ action: 'removed',
367
+ cwd: peer.cwd,
368
+ purgedState,
369
+ ...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
370
+ }
319
371
  }
320
372
 
321
373
  // ─────────────────────────────────────────────────────────────────────────────
@@ -616,7 +668,13 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
616
668
  errOut(`memory-plugin: ${r.error}\n`)
617
669
  return 1
618
670
  }
619
- out(`plugin: ${r.plugin!.name}@${r.plugin!.marketplace} (provider-declared)\n`)
671
+ // v1.2: a provision-declaring slot routes through the provider's commands —
672
+ // r.plugin is null then (there is no marketplace plugin in play to print).
673
+ out(
674
+ r.plugin
675
+ ? `plugin: ${r.plugin.name}@${r.plugin.marketplace} (provider-declared)\n`
676
+ : `provision: provider command (declaration v1.2, occasion=${state === 'on' ? 'sweep-on' : flags.all === true ? 'off-all' : 'off-peer'})\n`,
677
+ )
620
678
  for (const o of r.outcomes) {
621
679
  out(`${o.personality} (${o.runtime}): ${o.state}${o.detail ? ` — ${o.detail}` : ''}\n`)
622
680
  }
@@ -764,6 +822,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
764
822
  const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
765
823
  if (o.action === 'removed') {
766
824
  out(`removed "${o.personality}" from the registry\n`)
825
+ // v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
826
+ if (o.unprovision?.length) {
827
+ out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
828
+ }
767
829
  // Stale identity-keyed markers must die with the record (boris 10.06: a
768
830
  // namesake newborn inherited a dead peer's .stopped → refused to wake).
769
831
  if (o.purgedState?.length) {
@@ -101,3 +101,97 @@ describe('memory-plugin gating (slot-derived)', () => {
101
101
  expect(codexOutcomes.filter(x => x.state === 'already').length).toBe(1)
102
102
  })
103
103
  })
104
+
105
+ // ─── v1.2 provision-инверсия (контракт §Provision провайдера, ADR-009) ───────
106
+
107
+ import { chmodSync, readFileSync, existsSync } from 'fs'
108
+
109
+ /** Declare a v1.2 slot whose provision/unprovision are fake journaling scripts. */
110
+ function declareProvisionSlot(o: { provision?: boolean; unprovision?: boolean; alsoPlugin?: boolean; failing?: boolean }): {
111
+ journal: string
112
+ } {
113
+ const journal = join(root, 'journal.txt')
114
+ const script = join(root, 'fake-provider.sh')
115
+ writeFileSync(
116
+ script,
117
+ o.failing ? `#!/bin/sh\necho provider broken >&2\nexit 7\n` : `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`,
118
+ )
119
+ chmodSync(script, 0o755)
120
+ const block = (occ: string) => ({
121
+ command: script,
122
+ args: [occ, '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
123
+ })
124
+ writeFileSync(
125
+ memoryProviderPath(env()),
126
+ JSON.stringify({
127
+ ...DECLARATION,
128
+ ...(o.alsoPlugin ? { plugin: PLUGIN } : {}),
129
+ ...(o.provision !== false ? { provision: block('provision-peer') } : {}),
130
+ ...(o.unprovision !== false ? { unprovision: block('unprovision-peer') } : {}),
131
+ }),
132
+ )
133
+ return { journal }
134
+ }
135
+
136
+ describe('memory-plugin v1.2 provision routing', () => {
137
+ test('on --peer: provision command invoked per agentic runtime with occasion=sweep-on, substituted argv', async () => {
138
+ const { journal } = declareProvisionSlot({})
139
+ await upsertPeer({ personality: 'alpha', runtime: 'claude', cwd: join(root, 'alpha'), intelligence: 'artificial' }, { rootDir: root })
140
+ const r = memoryPluginApply('on', { peer: 'alpha', env: env() })
141
+ expect(r.ok).toBe(true)
142
+ expect(r.outcomes).toEqual([{ personality: 'alpha', runtime: 'claude', state: 'ok', detail: undefined }])
143
+ const j = readFileSync(journal, 'utf8').split('\n')
144
+ expect(j[0]).toBe('provision-peer')
145
+ expect(j.slice(1, 9)).toEqual(['--cwd', join(root, 'alpha'), '--runtime', 'claude', '--personality', 'alpha', '--occasion', 'sweep-on'])
146
+ })
147
+
148
+ test('PRECEDENCE: slot with BOTH blocks routes to provision; the plugin path is never touched', async () => {
149
+ const { journal } = declareProvisionSlot({ alsoPlugin: true })
150
+ await upsertPeer({ personality: 'beta', runtime: 'claude', cwd: join(root, 'beta'), intelligence: 'artificial' }, { rootDir: root })
151
+ const r = memoryPluginApply('on', { peer: 'beta', env: env() })
152
+ expect(r.ok).toBe(true)
153
+ // plugin path on this sandbox would yield 'runtime-missing' (bins at /nonexistent);
154
+ // 'ok' from the journaling script proves the provision command ran instead.
155
+ expect(r.outcomes[0]?.state).toBe('ok')
156
+ expect(readFileSync(journal, 'utf8')).toContain('provision-peer')
157
+ })
158
+
159
+ test('provision failure → ok=false with detail, NO fallback to the plugin path', async () => {
160
+ declareProvisionSlot({ failing: true, alsoPlugin: true })
161
+ await upsertPeer({ personality: 'gamma', runtime: 'claude', cwd: join(root, 'gamma'), intelligence: 'artificial' }, { rootDir: root })
162
+ const r = memoryPluginApply('on', { peer: 'gamma', env: env() })
163
+ expect(r.ok).toBe(false)
164
+ expect(r.outcomes[0]?.state).toBe('failed')
165
+ expect(r.outcomes[0]?.detail).toContain('provider broken')
166
+ })
167
+
168
+ test('off --peer → unprovision occasion=off-peer; off --all → off-all; codex has NO host-global special-casing', async () => {
169
+ const { journal } = declareProvisionSlot({})
170
+ await upsertPeer({ personality: 'cx', runtime: 'codex', cwd: join(root, 'cx'), intelligence: 'artificial' }, { rootDir: root })
171
+ const r1 = memoryPluginApply('off', { peer: 'cx', env: env() })
172
+ expect(r1.ok).toBe(true)
173
+ expect(r1.outcomes[0]).toEqual({ personality: 'cx', runtime: 'codex', state: 'ok', detail: undefined })
174
+ const r2 = memoryPluginApply('off', { all: true, env: env() })
175
+ expect(r2.ok).toBe(true)
176
+ const j = readFileSync(journal, 'utf8')
177
+ expect(j).toContain('off-peer')
178
+ expect(j).toContain('off-all')
179
+ expect(j).toContain('unprovision-peer')
180
+ })
181
+
182
+ test('missing direction block → explicit refusal (no silent skip, no plugin fallback)', async () => {
183
+ declareProvisionSlot({ unprovision: false, alsoPlugin: true })
184
+ await upsertPeer({ personality: 'delta', runtime: 'claude', cwd: join(root, 'delta'), intelligence: 'artificial' }, { rootDir: root })
185
+ const r = memoryPluginApply('off', { peer: 'delta', env: env() })
186
+ expect(r.ok).toBe(false)
187
+ expect(r.error).toContain('declares no unprovision command')
188
+ })
189
+
190
+ test('non-agentic peer → explicit skip (provision never invoked)', async () => {
191
+ const { journal } = declareProvisionSlot({})
192
+ await upsertPeer({ personality: 'tim2', runtime: 'notifier', cwd: join(root, 'tim2'), intelligence: 'absent' }, { rootDir: root })
193
+ const r = memoryPluginApply('on', { peer: 'tim2', env: env() })
194
+ expect(r.outcomes[0]?.state).toBe('skipped-no-agentic-runtime')
195
+ expect(existsSync(journal)).toBe(false)
196
+ })
197
+ })
@@ -12,14 +12,17 @@
12
12
  // the declaration — agreed AUTO-removal: a dead provider's plugin actively
13
13
  // errors in every session, unlike the native-memory restore asymmetry).
14
14
 
15
+ import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
15
16
  import { findPeer, readPeersIndex } from '../registry/index.ts'
16
- import { readMemoryProvider, type MemoryProviderPlugin } from '../status/index.ts'
17
+ import { readMemoryProvider, type MemoryProviderPlugin, type MemoryProviderProvision } from '../status/index.ts'
18
+ import { pluginLogsDir } from '../storage/index.ts'
17
19
  import {
18
20
  enableCapabilityForCwd,
19
21
  removeClaudeForCwd,
20
22
  removeCodexGlobal,
21
23
  type CapabilityRuntime,
22
24
  } from './index.ts'
25
+ import { runProvisionCommand, type ProvisionOccasion } from './provisionCommand.ts'
23
26
 
24
27
  export interface MemoryPluginOutcome {
25
28
  personality: string
@@ -58,6 +61,13 @@ export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions
58
61
  if (!slot) {
59
62
  return { ok: false, plugin: null, error: 'memory slot is EMPTY — no provider declared, nothing to install', outcomes: [] }
60
63
  }
64
+ // v1.2 PRECEDENCE (контракт §Provision провайдера): a provision-declaring slot
65
+ // routes BOTH verb directions through the provider's commands; the deprecated
66
+ // v1.1 plugin block is ignored entirely (no fallback — masking a provision
67
+ // failure would hide a broken provider behind a stale plugin install).
68
+ if (slot.provision || slot.unprovision) {
69
+ return memoryProvisionApply(state, slot.provision, slot.unprovision, slot.provider, opts, env)
70
+ }
61
71
  if (!slot.plugin) {
62
72
  return {
63
73
  ok: false,
@@ -131,3 +141,70 @@ export function memoryPluginApply(state: 'on' | 'off', opts: MemoryPluginOptions
131
141
  const failed = outcomes.some(o => o.state === 'failed' || o.state === 'setup-failed')
132
142
  return { ok: !failed, plugin, outcomes }
133
143
  }
144
+
145
+ /**
146
+ * v1.2 verb routing through the provider's provision/unprovision commands
147
+ * (occasion: on → sweep-on; off --peer → off-peer; off --all → off-all). One
148
+ * invocation per target peer × agentic runtime — NO host-global special-casing
149
+ * here: ref-counting of host-global surfaces (codex) moved to the PROVIDER with
150
+ * the inversion (contract requirement 4). A direction whose command block is
151
+ * absent is an explicit refusal (never silently skipped, never plugin-fallback).
152
+ */
153
+ function memoryProvisionApply(
154
+ state: 'on' | 'off',
155
+ provision: MemoryProviderProvision | undefined,
156
+ unprovision: MemoryProviderProvision | undefined,
157
+ provider: string,
158
+ opts: MemoryPluginOptions,
159
+ env: NodeJS.ProcessEnv,
160
+ ): MemoryPluginResult {
161
+ const block = state === 'on' ? provision : unprovision
162
+ if (!block) {
163
+ return {
164
+ ok: false,
165
+ plugin: null,
166
+ error:
167
+ `provider "${provider}" declares no ${state === 'on' ? 'provision' : 'unprovision'} command ` +
168
+ `(declaration v1.2 requires both directions) — fix the provider's declaration`,
169
+ outcomes: [],
170
+ }
171
+ }
172
+ const index = readPeersIndex({ env })
173
+ const targets = opts.all === true ? index.peers : opts.peer ? (p => (p ? [p] : []))(findPeer(index, opts.peer)) : []
174
+ if (targets.length === 0) {
175
+ return {
176
+ ok: false,
177
+ plugin: null,
178
+ error: opts.peer ? `peer "${opts.peer}" is not in the iapeer peers index` : 'no targets — pass --peer <p> or --all',
179
+ outcomes: [],
180
+ }
181
+ }
182
+ const occasion: ProvisionOccasion = state === 'on' ? 'sweep-on' : opts.all === true ? 'off-all' : 'off-peer'
183
+ const outcomes: MemoryPluginOutcome[] = []
184
+ for (const p of targets) {
185
+ const agentic = p.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
186
+ if (agentic.length === 0) {
187
+ outcomes.push({ personality: p.personality, runtime: 'none', state: 'skipped-no-agentic-runtime' })
188
+ continue
189
+ }
190
+ for (const rt of agentic) {
191
+ const o = runProvisionCommand({ block, cwd: p.cwd, runtime: rt, personality: p.personality, occasion, env })
192
+ appendLifecycleEvent(
193
+ pluginLogsDir('iapeer', { env }),
194
+ {
195
+ ev: 'memory-provision',
196
+ identity: `${rt}-${p.personality}`,
197
+ occasion,
198
+ state: o.state,
199
+ exit: o.exitCode ?? undefined,
200
+ ms: o.durationMs,
201
+ detail: o.detail,
202
+ },
203
+ { env },
204
+ )
205
+ outcomes.push({ personality: p.personality, runtime: rt, state: o.state, detail: o.detail })
206
+ }
207
+ }
208
+ const failed = outcomes.some(o => o.state !== 'ok' && o.state !== 'skipped-no-agentic-runtime')
209
+ return { ok: !failed, plugin: null, outcomes }
210
+ }
@@ -0,0 +1,107 @@
1
+ // Provision-command executor — the v1.2 inversion joint. Pins the four contract
2
+ // requirements: per-arg placeholder substitution WITHOUT a shell (injection
3
+ // class), absolute command, timeout, structured outcomes. The fake provider is
4
+ // a tmp shell script that journals its argv — no real provider involved.
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
8
+ import { tmpdir } from 'os'
9
+ import { join } from 'path'
10
+ import { runProvisionCommand } from './provisionCommand.ts'
11
+
12
+ let dir: string
13
+ beforeEach(() => {
14
+ dir = mkdtempSync(join(tmpdir(), 'iapeer-provcmd-'))
15
+ })
16
+ afterEach(() => {
17
+ rmSync(dir, { recursive: true, force: true })
18
+ })
19
+
20
+ /** A fake provider command journaling each argv element on its own line. */
21
+ function fakeProvider(name: string, body?: string): string {
22
+ const p = join(dir, name)
23
+ writeFileSync(p, `#!/bin/sh\n${body ?? `printf '%s\\n' "$@" > '${dir}/journal.txt'`}\n`)
24
+ chmodSync(p, 0o755)
25
+ return p
26
+ }
27
+
28
+ describe('runProvisionCommand (v1.2 executor)', () => {
29
+ test('per-arg substitution of {cwd}/{runtime}/{personality}/{occasion}, argv passed verbatim (no shell)', () => {
30
+ const cmd = fakeProvider('prov.sh')
31
+ const o = runProvisionCommand({
32
+ block: {
33
+ command: cmd,
34
+ // both shapes: bare placeholder arg and embedded `--k={v}`; plus an arg
35
+ // full of shell metacharacters that MUST stay literal (no shell layer)
36
+ args: ['provision-peer', '--cwd', '{cwd}', '--runtime={runtime}', '--occasion', '{occasion}', '$(reboot) `id` ; rm -rf /'],
37
+ },
38
+ cwd: '/tmp/some peer dir',
39
+ runtime: 'claude',
40
+ personality: 'tw-prov',
41
+ occasion: 'birth',
42
+ })
43
+ expect(o.state).toBe('ok')
44
+ expect(o.exitCode).toBe(0)
45
+ const journal = readFileSync(join(dir, 'journal.txt'), 'utf8').split('\n')
46
+ expect(journal[0]).toBe('provision-peer')
47
+ expect(journal[1]).toBe('--cwd')
48
+ expect(journal[2]).toBe('/tmp/some peer dir') // spaces survive — single argv element
49
+ expect(journal[3]).toBe('--runtime=claude') // embedded substitution
50
+ expect(journal[4]).toBe('--occasion')
51
+ expect(journal[5]).toBe('birth')
52
+ expect(journal[6]).toBe('$(reboot) `id` ; rm -rf /') // LITERAL — never shell-parsed
53
+ })
54
+
55
+ test('{personality} placeholder is supported (optional for the provider)', () => {
56
+ const cmd = fakeProvider('prov.sh')
57
+ const o = runProvisionCommand({
58
+ block: { command: cmd, args: ['{personality}'] },
59
+ cwd: '/x',
60
+ runtime: 'codex',
61
+ personality: 'boris',
62
+ occasion: 'sweep-on',
63
+ })
64
+ expect(o.state).toBe('ok')
65
+ expect(readFileSync(join(dir, 'journal.txt'), 'utf8').trim()).toBe('boris')
66
+ })
67
+
68
+ test('non-zero exit → failed with exit code and stderr tail', () => {
69
+ const cmd = fakeProvider('prov.sh', `echo 'no such peer' >&2; exit 3`)
70
+ const o = runProvisionCommand({
71
+ block: { command: cmd, args: [] },
72
+ cwd: '/x',
73
+ runtime: 'claude',
74
+ personality: 'p',
75
+ occasion: 'off-peer',
76
+ })
77
+ expect(o.state).toBe('failed')
78
+ expect(o.exitCode).toBe(3)
79
+ expect(o.detail).toContain('no such peer')
80
+ })
81
+
82
+ test('timeout → state=timeout, never hangs the joint', () => {
83
+ const cmd = fakeProvider('prov.sh', 'sleep 30')
84
+ const o = runProvisionCommand({
85
+ block: { command: cmd, args: [] },
86
+ cwd: '/x',
87
+ runtime: 'claude',
88
+ personality: 'p',
89
+ occasion: 'remove',
90
+ timeoutMs: 300,
91
+ })
92
+ expect(o.state).toBe('timeout')
93
+ expect(o.durationMs).toBeLessThan(5000)
94
+ })
95
+
96
+ test('missing/non-executable command → not-executable (never throws)', () => {
97
+ const o = runProvisionCommand({
98
+ block: { command: join(dir, 'nope.sh'), args: [] },
99
+ cwd: '/x',
100
+ runtime: 'claude',
101
+ personality: 'p',
102
+ occasion: 'birth',
103
+ })
104
+ expect(o.state).toBe('not-executable')
105
+ expect(o.exitCode).toBeNull()
106
+ })
107
+ })
@@ -0,0 +1,112 @@
1
+ // Provision-command executor — the v1.2 inversion joint (контракт «Слот памяти»
2
+ // §Provision провайдера; ADR-009, решение Артура 10.06, схема сверена с
3
+ // iapeer-memory 11.06). The slot declares provision/unprovision COMMANDS; the
4
+ // core shells into them at the lifecycle joints and knows NOTHING about surface
5
+ // forms. The four contract requirements live here:
6
+ // 1. argv-form {command, args[]} with PER-ARG placeholder substitution —
7
+ // spawned WITHOUT a shell (injection/quoting class; бэктик-грабли 10.06).
8
+ // 2. `command` is ABSOLUTE (parser refuses relative — launchd minimal PATH).
9
+ // 3. Timeout 120 s; best-effort semantics live at the CALL SITES (LOUD warn,
10
+ // the birth/remove flow continues) — this module only reports structurally.
11
+ // 4. {occasion} vocabulary: birth | sweep-on | off-peer | off-all | remove —
12
+ // ref-counting of host-global surfaces is the PROVIDER's business.
13
+ // Idempotency is the provider's obligation by construction; the provider holds
14
+ // its own lock against concurrent provisions.
15
+
16
+ import { spawnSync } from 'child_process'
17
+ import { accessSync, constants as FS } from 'fs'
18
+ import type { MemoryProviderProvision } from '../status/index.ts'
19
+
20
+ /** The agreed occasion vocabulary (финален, сверено с iapeer-memory). */
21
+ export type ProvisionOccasion = 'birth' | 'sweep-on' | 'off-peer' | 'off-all' | 'remove'
22
+
23
+ export const PROVISION_TIMEOUT_MS = 120_000
24
+
25
+ export interface ProvisionCommandSpec {
26
+ /** The declared command block (provision or unprovision). */
27
+ block: MemoryProviderProvision
28
+ /** Placeholder values — substituted PER-ARG, substring-level (so both
29
+ * `--cwd {cwd}` and `--cwd={cwd}` argv shapes work). */
30
+ cwd: string
31
+ runtime: string
32
+ personality: string
33
+ occasion: ProvisionOccasion
34
+ env?: NodeJS.ProcessEnv
35
+ timeoutMs?: number
36
+ }
37
+
38
+ export interface ProvisionCommandOutcome {
39
+ state: 'ok' | 'failed' | 'timeout' | 'not-executable'
40
+ exitCode: number | null
41
+ /** stderr tail (last ~400 chars) — the structured detail for eventlog/warns. */
42
+ detail?: string
43
+ durationMs: number
44
+ /** The fully substituted argv (command first) — logged for postmortems. */
45
+ argv: string[]
46
+ }
47
+
48
+ function substitute(arg: string, s: ProvisionCommandSpec): string {
49
+ return arg
50
+ .replaceAll('{cwd}', s.cwd)
51
+ .replaceAll('{runtime}', s.runtime)
52
+ .replaceAll('{personality}', s.personality)
53
+ .replaceAll('{occasion}', s.occasion)
54
+ }
55
+
56
+ function tail(text: string, max = 400): string | undefined {
57
+ const t = text.trim()
58
+ if (!t) return undefined
59
+ return t.length <= max ? t : `…${t.slice(-max)}`
60
+ }
61
+
62
+ /**
63
+ * Run ONE provider provision/unprovision command for ONE peer × runtime ×
64
+ * occasion. Synchronous (the call sites are the synchronous provision/remove
65
+ * flows), no shell, bounded by timeout. NEVER throws — every failure is a
66
+ * structured outcome the call site warns about (best-effort: a provider hiccup
67
+ * must not kill a birth or a remove).
68
+ */
69
+ export function runProvisionCommand(spec: ProvisionCommandSpec): ProvisionCommandOutcome {
70
+ const env = spec.env ?? process.env
71
+ const argv = [spec.block.command, ...spec.block.args.map(a => substitute(a, spec))]
72
+ const started = Date.now()
73
+ try {
74
+ accessSync(spec.block.command, FS.X_OK)
75
+ } catch {
76
+ return {
77
+ state: 'not-executable',
78
+ exitCode: null,
79
+ detail: `command not found or not executable: ${spec.block.command}`,
80
+ durationMs: Date.now() - started,
81
+ argv,
82
+ }
83
+ }
84
+ try {
85
+ const r = spawnSync(argv[0]!, argv.slice(1), {
86
+ env: env as Record<string, string>,
87
+ encoding: 'utf8',
88
+ timeout: spec.timeoutMs ?? PROVISION_TIMEOUT_MS,
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ })
91
+ const durationMs = Date.now() - started
92
+ // node/bun signal a timeout via error.code ETIMEDOUT + signal SIGTERM
93
+ if (r.error && (r.error as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
94
+ return { state: 'timeout', exitCode: null, detail: `timed out after ${spec.timeoutMs ?? PROVISION_TIMEOUT_MS} ms`, durationMs, argv }
95
+ }
96
+ if (r.error) {
97
+ return { state: 'failed', exitCode: null, detail: r.error.message, durationMs, argv }
98
+ }
99
+ if (r.status !== 0) {
100
+ return { state: 'failed', exitCode: r.status, detail: tail(r.stderr ?? ''), durationMs, argv }
101
+ }
102
+ return { state: 'ok', exitCode: 0, detail: tail(r.stderr ?? ''), durationMs, argv }
103
+ } catch (e) {
104
+ return {
105
+ state: 'failed',
106
+ exitCode: null,
107
+ detail: e instanceof Error ? e.message : String(e),
108
+ durationMs: Date.now() - started,
109
+ argv,
110
+ }
111
+ }
112
+ }
@@ -121,13 +121,59 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
121
121
  )
122
122
  }
123
123
  }
124
+ // Provider PROVISION command (контракт §Provision провайдера, declaration
125
+ // v1.2 — ADR-009 inversion; решение Артура 10.06, схема сверена с
126
+ // iapeer-memory 11.06): an occupied slot WITH a provision block shells into
127
+ // the PROVIDER's command per agentic runtime (occasion=birth) — the core
128
+ // knows nothing about surface forms. PRECEDENCE: provision WINS over the
129
+ // deprecated v1.1 plugin block, with NO runtime fallback to plugin on
130
+ // failure (masking a provision refusal would hide a broken provider —
131
+ // LOUD warn + repair-hint instead). Runs AFTER preTrustCodexCwd above, so
132
+ // the provider's per-peer codex surfaces land in an already-trusted cwd.
133
+ if (slot.provision) {
134
+ const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
135
+ const { appendLifecycleEvent } = await import('../lifecycle/eventlog.ts')
136
+ const { pluginLogsDir } = await import('../storage/index.ts')
137
+ const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
138
+ for (const rt of agentic) {
139
+ const o = runProvisionCommand({
140
+ block: slot.provision,
141
+ cwd,
142
+ runtime: rt,
143
+ personality: profile.personality,
144
+ occasion: 'birth',
145
+ env,
146
+ })
147
+ appendLifecycleEvent(
148
+ pluginLogsDir('iapeer', { env }),
149
+ {
150
+ ev: 'memory-provision',
151
+ identity: `${rt}-${profile.personality}`,
152
+ occasion: 'birth',
153
+ state: o.state,
154
+ exit: o.exitCode ?? undefined,
155
+ ms: o.durationMs,
156
+ detail: o.detail,
157
+ },
158
+ { env },
159
+ )
160
+ if (o.state !== 'ok') {
161
+ opts.warn?.(
162
+ `memory provision (${rt}) ${o.state.toUpperCase()} for "${profile.personality}"` +
163
+ `${o.detail ? `: ${o.detail}` : ''} — память пира не подключена; ` +
164
+ `repair: iapeer memory-plugin on --peer ${profile.personality}`,
165
+ )
166
+ }
167
+ }
168
+ }
124
169
  // Provider PLUGIN auto-install (контракт §Плагин провайдера, declaration
125
- // v1.1; agreed iapeer-memory + boris 10.06): an occupied slot WITH a plugin
126
- // block installs the provider's marketplace plugin into the newborn's scope
127
- // (claude project-scope in cwd; codex host-global, idempotent) «создал
128
- // пира память работает». A v1 declaration (no block) installs nothing.
170
+ // v1.1, DEPRECATED by §Provision; agreed iapeer-memory + boris 10.06): an
171
+ // occupied slot WITH a plugin block and WITHOUT a provision block installs
172
+ // the provider's marketplace plugin into the newborn's scope (claude
173
+ // project-scope in cwd; codex host-global, idempotent) «создал пира
174
+ // память работает». A v1 declaration (no block) installs nothing.
129
175
  // Best-effort with a LOUD warn; repair = `iapeer memory-plugin on --peer`.
130
- if (slot.plugin) {
176
+ else if (slot.plugin) {
131
177
  const { enableCapabilityForCwd } = await import('../enable/index.ts')
132
178
  const agentic = profile.runtimes.filter((r): r is 'claude' | 'codex' => r === 'claude' || r === 'codex')
133
179
  if (agentic.length > 0) {
@@ -223,4 +223,67 @@ describe('provisionPeer birth-time native-memory lever', () => {
223
223
  // the native lever still applied (same slot-gated block)
224
224
  expect(JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8')).autoMemoryEnabled).toBe(false)
225
225
  })
226
+
227
+ test('v1.2: slot with provision command → birth shells into it per runtime (occasion=birth), plugin path NOT taken', async () => {
228
+ const root = mkTmp()
229
+ const env = envFor(root)
230
+ mkdirSync(join(root, 'iapeer'), { recursive: true })
231
+ const journal = join(root, 'journal.txt')
232
+ const script = join(root, 'fake-provider.sh')
233
+ const { chmodSync } = await import('fs')
234
+ writeFileSync(script, `#!/bin/sh\nprintf '%s\\n' "$@" >> '${journal}'\n`)
235
+ chmodSync(script, 0o755)
236
+ writeFileSync(
237
+ join(root, 'iapeer', 'memory-provider.json'),
238
+ JSON.stringify({
239
+ provider: 'iapeer-memory',
240
+ package: '@agfpd/iapeer-memory',
241
+ version: '0.2.0',
242
+ registeredAt: 'x',
243
+ // BOTH blocks declared → provision must WIN (precedence, no plugin attempt)
244
+ plugin: { name: 'iapeer-memory', marketplace: 'agfpd', marketplaceRef: 'agfpd/agfpd-marketplace' },
245
+ provision: {
246
+ command: script,
247
+ args: ['provision-peer', '--cwd', '{cwd}', '--runtime', '{runtime}', '--personality', '{personality}', '--occasion', '{occasion}'],
248
+ },
249
+ }),
250
+ )
251
+ const cwd = join(root, 'wprov')
252
+ const warns: string[] = []
253
+ const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
254
+ expect(r.personality).toBe('wprov')
255
+ expect(warns).toEqual([]) // provision ok → no warn at all
256
+ const j = readFileSync(journal, 'utf8').split('\n')
257
+ expect(j[0]).toBe('provision-peer')
258
+ expect(j.slice(1, 9)).toEqual(['--cwd', cwd, '--runtime', 'claude', '--personality', 'wprov', '--occasion', 'birth'])
259
+ })
260
+
261
+ test('v1.2: provision command FAILS → LOUD warn with repair-hint, provision of the peer still SUCCEEDS (best-effort)', async () => {
262
+ const root = mkTmp()
263
+ const env = envFor(root)
264
+ mkdirSync(join(root, 'iapeer'), { recursive: true })
265
+ const script = join(root, 'fake-provider.sh')
266
+ const { chmodSync } = await import('fs')
267
+ writeFileSync(script, `#!/bin/sh\necho 'vault offline' >&2\nexit 9\n`)
268
+ chmodSync(script, 0o755)
269
+ writeFileSync(
270
+ join(root, 'iapeer', 'memory-provider.json'),
271
+ JSON.stringify({
272
+ provider: 'iapeer-memory',
273
+ package: '@agfpd/iapeer-memory',
274
+ version: '0.2.0',
275
+ registeredAt: 'x',
276
+ provision: { command: script, args: ['{occasion}'] },
277
+ }),
278
+ )
279
+ const cwd = join(root, 'wfail')
280
+ const warns: string[] = []
281
+ const r = await provisionPeer({ cwd, runtime: 'claude', env, warn: m => warns.push(m) })
282
+ expect(r.personality).toBe('wfail') // birth completed despite the provider hiccup
283
+ const w = warns.find(x => x.includes('memory provision'))
284
+ expect(w).toBeDefined()
285
+ expect(w).toContain('FAILED')
286
+ expect(w).toContain('vault offline')
287
+ expect(w).toContain('memory-plugin on --peer wfail') // repair-hint
288
+ })
226
289
  })
@@ -32,6 +32,21 @@ export interface MemoryProviderPlugin {
32
32
  marketplaceRef: string
33
33
  }
34
34
 
35
+ /** The provider's provision command (declaration v1.2, контракт §Provision
36
+ * провайдера — ADR-009 inversion, решение Артура 10.06 + дизайн-консенсус
37
+ * сторон). The slot declares not surfaces but a COMMAND the core shells into at
38
+ * the lifecycle joints (birth / verb sweeps / remove); surface forms and their
39
+ * uninstall accounting live entirely with the provider. The `command` must be
40
+ * ABSOLUTE (the daemon runs under launchd's minimal PATH); `args` get PER-ARG
41
+ * placeholder substitution ({cwd}/{runtime}/{personality}/{occasion}) and are
42
+ * spawned WITHOUT a shell (injection/quoting class — бэктик-грабли 10.06). */
43
+ export interface MemoryProviderProvision {
44
+ /** Absolute path to the provider's executable. */
45
+ command: string
46
+ /** Argv tail; placeholders are substituted per-argument, never shell-parsed. */
47
+ args: string[]
48
+ }
49
+
35
50
  export interface MemoryProvider {
36
51
  /** Provider name occupying the slot (e.g. "iapeer-memory"). */
37
52
  provider: string
@@ -42,8 +57,15 @@ export interface MemoryProvider {
42
57
  /** Optional liveness proxy: an absolute path whose mtime the provider's daemon
43
58
  * refreshes. status reports its age; the core takes NO action on staleness. */
44
59
  heartbeat?: string
45
- /** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin. */
60
+ /** Optional marketplace-plugin declaration (v1.1) — see MemoryProviderPlugin.
61
+ * DEPRECATED by v1.2: when `provision` is also present, provision WINS and
62
+ * the plugin block is ignored (no runtime fallback — an explicit precedence,
63
+ * agreed iapeer-memory 11.06). */
46
64
  plugin?: MemoryProviderPlugin
65
+ /** Optional v1.2 provision command (peer-birth / sweep-on joints). */
66
+ provision?: MemoryProviderProvision
67
+ /** Optional v1.2 unprovision command (off-peer / off-all / remove joints). */
68
+ unprovision?: MemoryProviderProvision
47
69
  }
48
70
 
49
71
  /** Parse the optional v1.1 `plugin` block; anything short of three non-empty
@@ -57,6 +79,18 @@ function parsePluginBlock(raw: unknown): MemoryProviderPlugin | undefined {
57
79
  return { name: o.name.trim(), marketplace: o.marketplace.trim(), marketplaceRef: o.marketplaceRef.trim() }
58
80
  }
59
81
 
82
+ /** Parse an optional v1.2 `provision`/`unprovision` block. Fail-open like the
83
+ * plugin block: anything short of an ABSOLUTE command string + an array of
84
+ * strings → undefined (treated as absent). A relative command is INVALID by
85
+ * contract (launchd minimal PATH would resolve it differently per caller). */
86
+ function parseProvisionBlock(raw: unknown): MemoryProviderProvision | undefined {
87
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
88
+ const o = raw as Record<string, unknown>
89
+ if (typeof o.command !== 'string' || !o.command.trim().startsWith('/')) return undefined
90
+ if (!Array.isArray(o.args) || o.args.some(a => typeof a !== 'string')) return undefined
91
+ return { command: o.command.trim(), args: (o.args as string[]).slice() }
92
+ }
93
+
60
94
  export function memoryProviderPath(env: NodeJS.ProcessEnv = process.env): string {
61
95
  return join(resolveGlobalRoot(env), MEMORY_PROVIDER_FILE)
62
96
  }
@@ -74,6 +108,8 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
74
108
  if (typeof o.package !== 'string' || !o.package.trim()) return null
75
109
  if (typeof o.version !== 'string' || !o.version.trim()) return null
76
110
  const plugin = parsePluginBlock(o.plugin)
111
+ const provision = parseProvisionBlock(o.provision)
112
+ const unprovision = parseProvisionBlock(o.unprovision)
77
113
  return {
78
114
  provider: o.provider.trim(),
79
115
  package: o.package.trim(),
@@ -81,6 +117,8 @@ export function readMemoryProvider(env: NodeJS.ProcessEnv = process.env): Memory
81
117
  registeredAt: typeof o.registeredAt === 'string' ? o.registeredAt : '',
82
118
  ...(typeof o.heartbeat === 'string' && o.heartbeat.trim() ? { heartbeat: o.heartbeat.trim() } : {}),
83
119
  ...(plugin ? { plugin } : {}),
120
+ ...(provision ? { provision } : {}),
121
+ ...(unprovision ? { unprovision } : {}),
84
122
  }
85
123
  } catch {
86
124
  return null // empty slot — bare core is valid
@@ -81,6 +81,33 @@ describe('readMemoryProvider (slot declaration, fail-open)', () => {
81
81
  expect(p?.plugin).toBeUndefined()
82
82
  }
83
83
  })
84
+
85
+ test('v1.2 provision/unprovision blocks: parsed when valid; relative command or bad args = absent (fail-open)', () => {
86
+ const root = mkTmp()
87
+ const env = envFor(root)
88
+ const provision = { command: '/usr/local/bin/iapeer-memory', args: ['provision-peer', '--cwd', '{cwd}'] }
89
+ const unprovision = { command: '/usr/local/bin/iapeer-memory', args: ['unprovision-peer'] }
90
+ writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision, unprovision }))
91
+ const p = readMemoryProvider(env)
92
+ expect(p?.provision).toEqual(provision)
93
+ expect(p?.unprovision).toEqual(unprovision)
94
+ // empty args array is VALID (command alone)
95
+ writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: { command: '/x/y', args: [] } }))
96
+ expect(readMemoryProvider(env)?.provision).toEqual({ command: '/x/y', args: [] })
97
+ // invalid shapes → block absent, declaration itself stays valid
98
+ for (const bad of [
99
+ { command: 'iapeer-memory', args: [] }, // RELATIVE command (launchd minimal PATH) — contract-invalid
100
+ { command: '/x/y' }, // args missing
101
+ { command: '/x/y', args: ['ok', 42] }, // non-string arg
102
+ { args: ['provision-peer'] }, // command missing
103
+ 'provision-me', // not an object
104
+ ]) {
105
+ writeFileSync(memoryProviderPath(env), JSON.stringify({ ...VALID, provision: bad }))
106
+ const r = readMemoryProvider(env)
107
+ expect(r).not.toBeNull()
108
+ expect(r?.provision).toBeUndefined()
109
+ }
110
+ })
84
111
  })
85
112
 
86
113
  describe('heartbeatAgeSecs', () => {