@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.82

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.
Files changed (201) hide show
  1. package/README.md +11 -2
  2. package/dist/node/index.cjs +77 -38
  3. package/dist/node/index.d.cts +946 -0
  4. package/dist/node/index.d.cts.map +1 -0
  5. package/dist/node/index.d.ts +564 -51
  6. package/dist/node/index.d.ts.map +1 -1
  7. package/dist/node/index.js +77 -38
  8. package/dist/node/index.js.map +1 -1
  9. package/package.json +39 -16
  10. package/src/advisor/advisor-command-module.ts +47 -0
  11. package/src/advisor/advisor-command.test.ts +102 -0
  12. package/src/advisor/advisor-command.ts +55 -0
  13. package/src/advisor/advisor-cost-functional.test.ts +92 -0
  14. package/src/advisor/index.ts +6 -0
  15. package/src/agent/__tests__/agent-command.test.ts +39 -8
  16. package/src/agent/agent-command-module.ts +14 -7
  17. package/src/agent/agent-command-parser.ts +1 -1
  18. package/src/agent/agent-command.ts +3 -3
  19. package/src/background/__tests__/background-command-module.test.ts +59 -52
  20. package/src/background/background-command-module.ts +2 -1
  21. package/src/background/background-command.ts +3 -3
  22. package/src/command-module-utils.ts +33 -0
  23. package/src/compact/__tests__/compact-command-module.test.ts +31 -23
  24. package/src/compact/compact-command-module.ts +10 -1
  25. package/src/compact/compact-command.ts +6 -3
  26. package/src/context/__tests__/context-command-module.test.ts +214 -63
  27. package/src/context/auto-compact-format.ts +25 -0
  28. package/src/context/context-breakdown.ts +295 -0
  29. package/src/context/context-command-module.ts +45 -14
  30. package/src/context/context-command.ts +34 -257
  31. package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
  32. package/src/default/__tests__/default-command-modules.test.ts +59 -7
  33. package/src/default/__tests__/model-exposure.test.ts +241 -0
  34. package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
  35. package/src/default/default-command-modules.ts +130 -18
  36. package/src/default/index.ts +4 -1
  37. package/src/devices/__tests__/devices-command-module.test.ts +212 -0
  38. package/src/devices/devices-command-module.ts +224 -0
  39. package/src/devices/devices-command-port.ts +82 -0
  40. package/src/devices/index.ts +11 -0
  41. package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
  42. package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
  43. package/src/doctor/__tests__/doctor-fixture.ts +145 -0
  44. package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
  45. package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
  46. package/src/doctor/doctor-command-module.ts +132 -0
  47. package/src/doctor/doctor-extensions-probe.ts +299 -0
  48. package/src/doctor/doctor-node-deps.ts +89 -0
  49. package/src/doctor/doctor-redaction.ts +99 -0
  50. package/src/doctor/doctor-render.ts +70 -0
  51. package/src/doctor/doctor-repair.ts +136 -0
  52. package/src/doctor/doctor-runner.ts +122 -0
  53. package/src/doctor/doctor-settings-probe.ts +239 -0
  54. package/src/doctor/doctor-storage-probe.ts +187 -0
  55. package/src/doctor/doctor-types.ts +109 -0
  56. package/src/doctor/index.ts +32 -0
  57. package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
  58. package/src/editor/editor-command-module.ts +6 -5
  59. package/src/editor/editor-command.ts +10 -5
  60. package/src/effort/effort-command-module.ts +51 -0
  61. package/src/effort/effort-command.test.ts +140 -0
  62. package/src/effort/effort-command.ts +122 -0
  63. package/src/effort/index.ts +6 -0
  64. package/src/exit/__tests__/exit-command-module.test.ts +16 -9
  65. package/src/exit/exit-command-module.ts +2 -1
  66. package/src/exit/exit-command.ts +6 -6
  67. package/src/fork/__tests__/fork-command.test.ts +184 -0
  68. package/src/fork/fork-command-module.ts +61 -0
  69. package/src/fork/fork-command.ts +162 -0
  70. package/src/fork/index.ts +8 -0
  71. package/src/git/__tests__/fake-git-port.ts +28 -0
  72. package/src/git/__tests__/git-command-module.test.ts +103 -0
  73. package/src/git/__tests__/git-commit.test.ts +185 -0
  74. package/src/git/__tests__/git-diff.test.ts +125 -0
  75. package/src/git/__tests__/git-process.test.ts +221 -0
  76. package/src/git/__tests__/git-status.test.ts +117 -0
  77. package/src/git/git-command-module.ts +139 -0
  78. package/src/git/git-commit.ts +251 -0
  79. package/src/git/git-diff.ts +129 -0
  80. package/src/git/git-process.ts +169 -0
  81. package/src/git/git-status.ts +134 -0
  82. package/src/git/index.ts +17 -0
  83. package/src/goal/__tests__/goal-command.test.ts +9 -4
  84. package/src/goal/goal-command-module.ts +2 -1
  85. package/src/goal/goal-command.ts +6 -9
  86. package/src/handoff/__tests__/handoff-command-module.test.ts +24 -0
  87. package/src/handoff/__tests__/handoff-command.test.ts +218 -0
  88. package/src/handoff/handoff-command-module.ts +49 -0
  89. package/src/handoff/handoff-command.ts +187 -0
  90. package/src/handoff/index.ts +6 -0
  91. package/src/help/__tests__/help-command-module.test.ts +63 -40
  92. package/src/help/__tests__/help-command.test.ts +46 -37
  93. package/src/help/help-command-module.ts +2 -1
  94. package/src/help/help-command.ts +3 -3
  95. package/src/index.ts +124 -2
  96. package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
  97. package/src/keybindings/index.ts +6 -0
  98. package/src/keybindings/keybindings-command-module.ts +74 -0
  99. package/src/language/__tests__/language-command-module.test.ts +33 -28
  100. package/src/language/language-command-module.ts +2 -1
  101. package/src/language/language-command.ts +8 -6
  102. package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
  103. package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
  104. package/src/mcp-activation/index.ts +12 -0
  105. package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
  106. package/src/mcp-activation/mcp-activation-command.ts +448 -0
  107. package/src/mcp-activation/mcp-model-notice.ts +85 -0
  108. package/src/mcp-activation/mcp-model-status.ts +99 -0
  109. package/src/memory/__tests__/memory-command-module.test.ts +167 -74
  110. package/src/memory/memory-command-module.ts +18 -1
  111. package/src/memory/memory-command.ts +38 -38
  112. package/src/mode/__tests__/mode-command-module.test.ts +51 -38
  113. package/src/mode/mode-command-module.ts +2 -1
  114. package/src/mode/mode-command.ts +15 -7
  115. package/src/output-style/__tests__/output-style-command.test.ts +96 -0
  116. package/src/output-style/index.ts +6 -0
  117. package/src/output-style/output-style-command-module.ts +143 -0
  118. package/src/peers/__tests__/peers-command.test.ts +321 -0
  119. package/src/peers/index.ts +9 -0
  120. package/src/peers/peers-command-module.ts +49 -0
  121. package/src/peers/peers-command.ts +191 -0
  122. package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
  123. package/src/permissions/permissions-command-module.ts +2 -1
  124. package/src/permissions/permissions-command.ts +46 -4
  125. package/src/plan/__tests__/plan-command.test.ts +129 -0
  126. package/src/plan/index.ts +9 -0
  127. package/src/plan/plan-command-module.ts +49 -0
  128. package/src/plan/plan-command.ts +92 -0
  129. package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
  130. package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
  131. package/src/plugin/plugin-command-module.ts +3 -1
  132. package/src/plugin/plugin-command.ts +17 -16
  133. package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
  134. package/src/preset/__tests__/preset-command-module.test.ts +102 -56
  135. package/src/preset/preset-command-module.ts +26 -8
  136. package/src/preset/preset-command.ts +85 -27
  137. package/src/provider/__tests__/org-policy.test.ts +4 -5
  138. package/src/provider/__tests__/provider-command-module.test.ts +11 -14
  139. package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
  140. package/src/provider/__tests__/scripted-interaction.ts +7 -5
  141. package/src/provider/provider-command-execution.ts +4 -4
  142. package/src/provider/provider-command-module.ts +2 -1
  143. package/src/provider/provider-command-profile-lifecycle.ts +4 -4
  144. package/src/provider/provider-command-profile-operations.ts +3 -3
  145. package/src/provider/provider-command-profile.ts +1 -1
  146. package/src/provider/provider-command-setup.ts +4 -4
  147. package/src/provider/provider-startup.ts +62 -35
  148. package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
  149. package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
  150. package/src/remote-control/index.ts +8 -0
  151. package/src/remote-control/remote-control-command-module.ts +61 -0
  152. package/src/remote-control/remote-control-command.ts +138 -0
  153. package/src/reset/__tests__/reset-command-module.test.ts +5 -5
  154. package/src/reset/reset-command-module.ts +2 -1
  155. package/src/reset/reset-command.ts +3 -4
  156. package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
  157. package/src/rewind/rewind-command-module.ts +2 -1
  158. package/src/rewind/rewind-command.ts +73 -6
  159. package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
  160. package/src/sandbox/index.ts +2 -0
  161. package/src/sandbox/sandbox-command-module.ts +56 -0
  162. package/src/sandbox/sandbox-command.ts +94 -0
  163. package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
  164. package/src/schedule/__tests__/loop-command.test.ts +360 -0
  165. package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
  166. package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
  167. package/src/schedule/__tests__/schedule-command.test.ts +143 -3
  168. package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
  169. package/src/schedule/index.ts +3 -0
  170. package/src/schedule/loop-command.ts +333 -0
  171. package/src/schedule/loop-jitter.ts +41 -0
  172. package/src/schedule/schedule-command-module.ts +92 -14
  173. package/src/schedule/schedule-command.ts +98 -9
  174. package/src/schedule/schedule-spec-parser.ts +5 -1
  175. package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
  176. package/src/session/__tests__/session-command-module.test.ts +210 -45
  177. package/src/session/session-command-module.ts +68 -37
  178. package/src/session/session-command.ts +147 -80
  179. package/src/settings/settings-command-module.ts +3 -2
  180. package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
  181. package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
  182. package/src/shell/resolve-shell.ts +2 -2
  183. package/src/shell/shell-command-module.ts +6 -5
  184. package/src/shell/shell-command.ts +9 -5
  185. package/src/skills/__tests__/skills-command-module.test.ts +69 -28
  186. package/src/skills/index.ts +1 -0
  187. package/src/skills/skills-command-module.ts +22 -5
  188. package/src/skills/skills-command.ts +14 -13
  189. package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
  190. package/src/statusline/statusline-command-module.ts +2 -1
  191. package/src/statusline/statusline-command.ts +4 -4
  192. package/src/theme/__tests__/theme-command.test.ts +204 -0
  193. package/src/theme/index.ts +12 -0
  194. package/src/theme/theme-command-module.ts +211 -0
  195. package/src/user-local/__tests__/user-local-command.test.ts +145 -110
  196. package/src/user-local/user-local-command-constants.ts +1 -1
  197. package/src/user-local/user-local-command-module.ts +6 -5
  198. package/src/user-local/user-local-command.ts +12 -6
  199. package/src/user-local/user-local-memory-command.ts +17 -7
  200. package/src/plugins/default-plugin-command-adapter.ts +0 -164
  201. package/src/plugins/default-plugin-command-source-loader.ts +0 -31
@@ -1,15 +1,73 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import {
6
6
  InteractiveSession,
7
7
  SystemCommandExecutor,
8
- createCommandPendingMemoryStore,
8
+ WorkspaceTrustService,
9
+ createWorkspaceMemoryStore,
10
+ getWorkspaceProjectStateStorage,
9
11
  } from '@robota-sdk/agent-framework';
10
12
  import { MemoryCommandSource, createMemoryCommandModule, executeMemoryCommand } from '../index.js';
13
+ import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
11
14
 
12
- const TMP_BASE = join(tmpdir(), `robota-command-memory-${process.pid}`);
15
+ import type {
16
+ IMemoryStore,
17
+ IWorkspaceIdentity,
18
+ IWorkspaceTrustStore,
19
+ IWorkspaceTrustStoreSnapshot,
20
+ } from '@robota-sdk/agent-framework';
21
+
22
+ // SEC-003 (js/insecure-temporary-file): `mkdtempSync` yields a 0700 directory with an unpredictable
23
+ // name, so no other user can pre-create or read the paths this suite writes under it.
24
+ const TMP_BASE = realpathSync(mkdtempSync(join(tmpdir(), 'robota-command-memory-')));
25
+ const memoryStores = new Map<string, Promise<IMemoryStore>>();
26
+
27
+ class TrustedStore implements IWorkspaceTrustStore {
28
+ inspect(): Promise<IWorkspaceTrustStoreSnapshot> {
29
+ return Promise.resolve({ state: 'trusted', generation: 1 });
30
+ }
31
+
32
+ grant(): Promise<IWorkspaceTrustStoreSnapshot> {
33
+ return this.inspect();
34
+ }
35
+
36
+ revoke(): Promise<IWorkspaceTrustStoreSnapshot> {
37
+ return Promise.resolve({ state: 'revoked', generation: 2 });
38
+ }
39
+ }
40
+
41
+ function createMemoryStore(cwd: string): Promise<IMemoryStore> {
42
+ const existing = memoryStores.get(cwd);
43
+ if (existing !== undefined) return existing;
44
+ const created = (async () => {
45
+ const root = realpathSync(cwd);
46
+ const identity: IWorkspaceIdentity = {
47
+ repositoryKey: `memory-test:${root}`,
48
+ displayPath: root,
49
+ worktreeRoot: root,
50
+ };
51
+ const service = new WorkspaceTrustService({
52
+ identityResolver: { resolve: () => identity },
53
+ store: new TrustedStore(),
54
+ projectStateDirectories: {
55
+ sessions: join('.robota', 'sessions'),
56
+ 'session-logs': join('.robota', 'logs'),
57
+ memory: join('.robota', 'memory'),
58
+ checkpoints: join('.robota', 'checkpoints'),
59
+ },
60
+ });
61
+ const access = await service.inspect(root);
62
+ if (access.status !== 'trusted') throw new Error('Expected trusted project access.');
63
+ return createWorkspaceMemoryStore(
64
+ getWorkspaceProjectStateStorage(access.authority, 'memory'),
65
+ () => new Date('2026-05-02T00:00:00.000Z'),
66
+ );
67
+ })();
68
+ memoryStores.set(cwd, created);
69
+ return created;
70
+ }
13
71
 
14
72
  function makeProject(): string {
15
73
  const dir = join(TMP_BASE, Math.random().toString(36).slice(2));
@@ -43,20 +101,18 @@ function createMockRuntimeSession() {
43
101
  };
44
102
  }
45
103
 
46
- function createInteractiveSession(cwd = makeProject()): InteractiveSession {
104
+ async function createInteractiveSession(cwd = makeProject()): Promise<InteractiveSession> {
47
105
  return new InteractiveSession({
48
106
  cwd,
49
107
  session: createMockRuntimeSession() as never,
108
+ memoryStore: await createMemoryStore(cwd),
50
109
  commandModules: [createMemoryCommandModule()],
51
110
  });
52
111
  }
53
112
 
54
- function seedPendingMemory(cwd: string): void {
55
- const pendingStore = createCommandPendingMemoryStore(
56
- cwd,
57
- () => new Date('2026-05-02T00:00:00.000Z'),
58
- );
59
- pendingStore.upsert(
113
+ async function seedPendingMemory(cwd: string): Promise<void> {
114
+ const store = await createMemoryStore(cwd);
115
+ await store.upsertPending(
60
116
  {
61
117
  id: 'mem_123',
62
118
  type: 'project',
@@ -73,6 +129,7 @@ function seedPendingMemory(cwd: string): void {
73
129
  }
74
130
 
75
131
  afterEach(() => {
132
+ memoryStores.clear();
76
133
  if (existsSync(TMP_BASE)) rmSync(TMP_BASE, { recursive: true, force: true });
77
134
  });
78
135
 
@@ -121,46 +178,73 @@ describe('createMemoryCommandModule', () => {
121
178
  kind: 'builtin-command',
122
179
  userInvocable: true,
123
180
  modelInvocable: true,
181
+ // The model is offered only what it may run: `approve`/`reject` are the user's review.
124
182
  argumentHint:
125
- 'list | show [topic] | add <user|feedback|project|reference> <topic> <text> | pending | approve <id> | reject <id> | used',
183
+ '[list | show [topic] | add <user|feedback|project|reference> <topic> <text> | pending | used]',
126
184
  safety: 'write',
127
185
  }),
128
186
  );
129
- expect(descriptor?.description).toContain(
130
- 'inspect project memory when stored context may help',
131
- );
187
+ expect(descriptor?.description).toContain('look up stored conventions');
188
+ expect(descriptor?.description).toContain('suggest `/memory approve <id>`');
189
+ });
190
+
191
+ it('refuses a model-issued `memory approve` before the command runs, and allows the user', async () => {
192
+ const executor = new SystemCommandExecutor([
193
+ ...(createMemoryCommandModule().systemCommands ?? []),
194
+ ]);
195
+ const command = executor.getCommand('memory');
196
+ const execute = vi.fn();
197
+ const host = createTestCommandHost();
198
+ const spied = { ...command!, execute };
199
+ const gated = new SystemCommandExecutor([spied]);
200
+
201
+ for (const args of ['approve cand-1', 'reject cand-1', 'APPROVE cand-1']) {
202
+ const result = await gated.executeModelInvocable('memory', host, args);
203
+ expect(result?.success).toBe(false);
204
+ expect(result?.message).toContain('only the user can');
205
+ }
206
+ expect(execute).not.toHaveBeenCalled();
207
+
208
+ await gated.executeModelInvocable('memory', host, 'list');
209
+ await gated.execute('memory', host, 'approve cand-1');
210
+ expect(execute).toHaveBeenCalledTimes(2);
132
211
  });
133
212
  });
134
213
 
135
214
  describe('executeMemoryCommand', () => {
136
215
  it('lists configured memory paths', async () => {
137
216
  const cwd = makeProject();
138
- const session = createInteractiveSession(cwd);
217
+ const session = await createInteractiveSession(cwd);
139
218
 
140
219
  const result = await session.executeCommand('memory', 'list');
141
220
 
142
221
  expect(result?.success).toBe(true);
143
- expect(result?.message).toContain(join(cwd, '.robota', 'memory', 'MEMORY.md'));
222
+ expect(result?.message).toContain(join('.robota', 'memory', 'MEMORY.md'));
144
223
  });
145
224
 
146
- it('persists index and topic entries through slash invocation', async () => {
147
- const cwd = makeProject();
148
- const session = createInteractiveSession(cwd);
149
-
150
- const result = await session.executeCommand(
151
- 'memory',
152
- 'add project build Use pnpm for scripts.',
153
- );
154
-
155
- expect(result?.success).toBe(true);
156
- expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
157
- '(project/build) Use pnpm for scripts.',
158
- );
159
- });
225
+ // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
226
+ it.runIf(process.platform === 'linux')(
227
+ 'persists index and topic entries through slash invocation',
228
+ async () => {
229
+ const cwd = makeProject();
230
+ const session = await createInteractiveSession(cwd);
231
+
232
+ const result = await session.executeCommand(
233
+ 'memory',
234
+ 'add project build Use pnpm for scripts.',
235
+ );
236
+
237
+ expect(result?.success).toBe(true);
238
+ expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
239
+ '(project/build) Use pnpm for scripts.',
240
+ );
241
+ },
242
+ );
160
243
 
161
- it('uses the same handler for model invocation', async () => {
244
+ // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
245
+ it.runIf(process.platform === 'linux')('uses the same handler for model invocation', async () => {
162
246
  const cwd = makeProject();
163
- const session = createInteractiveSession(cwd);
247
+ const session = await createInteractiveSession(cwd);
164
248
 
165
249
  const result = await session.executeModelCommand(
166
250
  'memory',
@@ -175,7 +259,7 @@ describe('executeMemoryCommand', () => {
175
259
 
176
260
  it('rejects sensitive content before writing files', async () => {
177
261
  const cwd = makeProject();
178
- const session = createInteractiveSession(cwd);
262
+ const session = await createInteractiveSession(cwd);
179
263
 
180
264
  const result = await session.executeCommand(
181
265
  'memory',
@@ -187,10 +271,11 @@ describe('executeMemoryCommand', () => {
187
271
  expect(existsSync(join(cwd, '.robota', 'memory', 'MEMORY.md'))).toBe(false);
188
272
  });
189
273
 
190
- it('lists queued automatic memory candidates', async () => {
274
+ // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
275
+ it.runIf(process.platform === 'linux')('lists queued automatic memory candidates', async () => {
191
276
  const cwd = makeProject();
192
- seedPendingMemory(cwd);
193
- const session = createInteractiveSession(cwd);
277
+ await seedPendingMemory(cwd);
278
+ const session = await createInteractiveSession(cwd);
194
279
 
195
280
  const result = await session.executeCommand('memory', 'pending');
196
281
 
@@ -200,47 +285,55 @@ describe('executeMemoryCommand', () => {
200
285
  expect(result?.message).toContain('Use pnpm for package scripts.');
201
286
  });
202
287
 
203
- it('approves and saves a pending candidate while recording audit events', async () => {
204
- const cwd = makeProject();
205
- seedPendingMemory(cwd);
206
- const session = createInteractiveSession(cwd);
207
- const recordMemoryEvent = vi.spyOn(session, 'recordMemoryEvent');
208
-
209
- const result = await session.executeCommand('memory', 'approve mem_123');
210
-
211
- expect(result?.success).toBe(true);
212
- expect(result?.message).toContain('Saved memory candidate mem_123');
213
- expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
214
- '(project/build) Use pnpm for package scripts.',
215
- );
216
- expect(createCommandPendingMemoryStore(cwd).get('mem_123')?.status).toBe('saved');
217
- expect(recordMemoryEvent).toHaveBeenCalledWith(
218
- expect.objectContaining({ type: 'memory_candidate_approved', candidateId: 'mem_123' }),
219
- );
220
- expect(recordMemoryEvent).toHaveBeenCalledWith(
221
- expect.objectContaining({ type: 'memory_candidate_saved', candidateId: 'mem_123' }),
222
- );
223
- });
224
-
225
- it('rejects a pending candidate while recording an audit event', async () => {
226
- const cwd = makeProject();
227
- seedPendingMemory(cwd);
228
- const session = createInteractiveSession(cwd);
229
- const recordMemoryEvent = vi.spyOn(session, 'recordMemoryEvent');
230
-
231
- const result = await session.executeCommand('memory', 'reject mem_123');
288
+ // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
289
+ it.runIf(process.platform === 'linux')(
290
+ 'approves and saves a pending candidate while recording audit events',
291
+ async () => {
292
+ const cwd = makeProject();
293
+ await seedPendingMemory(cwd);
294
+ const session = await createInteractiveSession(cwd);
295
+ const recordMemoryEvent = vi.spyOn(session, 'recordMemoryEvent');
296
+
297
+ const result = await session.executeCommand('memory', 'approve mem_123');
298
+
299
+ expect(result?.success).toBe(true);
300
+ expect(result?.message).toContain('Saved memory candidate mem_123');
301
+ expect(readFileSync(join(cwd, '.robota', 'memory', 'MEMORY.md'), 'utf8')).toContain(
302
+ '(project/build) Use pnpm for package scripts.',
303
+ );
304
+ expect((await (await createMemoryStore(cwd)).getPending('mem_123'))?.status).toBe('saved');
305
+ expect(recordMemoryEvent).toHaveBeenCalledWith(
306
+ expect.objectContaining({ type: 'memory_candidate_approved', candidateId: 'mem_123' }),
307
+ );
308
+ expect(recordMemoryEvent).toHaveBeenCalledWith(
309
+ expect.objectContaining({ type: 'memory_candidate_saved', candidateId: 'mem_123' }),
310
+ );
311
+ },
312
+ );
232
313
 
233
- expect(result?.success).toBe(true);
234
- expect(result?.message).toContain('Rejected memory candidate mem_123');
235
- expect(createCommandPendingMemoryStore(cwd).get('mem_123')?.status).toBe('rejected');
236
- expect(recordMemoryEvent).toHaveBeenCalledWith(
237
- expect.objectContaining({ type: 'memory_candidate_rejected', candidateId: 'mem_123' }),
238
- );
239
- });
314
+ // ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
315
+ it.runIf(process.platform === 'linux')(
316
+ 'rejects a pending candidate while recording an audit event',
317
+ async () => {
318
+ const cwd = makeProject();
319
+ await seedPendingMemory(cwd);
320
+ const session = await createInteractiveSession(cwd);
321
+ const recordMemoryEvent = vi.spyOn(session, 'recordMemoryEvent');
322
+
323
+ const result = await session.executeCommand('memory', 'reject mem_123');
324
+
325
+ expect(result?.success).toBe(true);
326
+ expect(result?.message).toContain('Rejected memory candidate mem_123');
327
+ expect((await (await createMemoryStore(cwd)).getPending('mem_123'))?.status).toBe('rejected');
328
+ expect(recordMemoryEvent).toHaveBeenCalledWith(
329
+ expect.objectContaining({ type: 'memory_candidate_rejected', candidateId: 'mem_123' }),
330
+ );
331
+ },
332
+ );
240
333
 
241
334
  it('reports references from the current turn', async () => {
242
335
  const cwd = makeProject();
243
- const session = createInteractiveSession(cwd);
336
+ const session = await createInteractiveSession(cwd);
244
337
  vi.spyOn(session, 'getUsedMemoryReferences').mockReturnValue([
245
338
  {
246
339
  topic: 'build',
@@ -259,7 +352,7 @@ describe('executeMemoryCommand', () => {
259
352
 
260
353
  it('returns usage for invalid arguments without mutating state', async () => {
261
354
  const cwd = makeProject();
262
- const session = createInteractiveSession(cwd);
355
+ const session = await createInteractiveSession(cwd);
263
356
 
264
357
  const result = await executeMemoryCommand(session, 'add project missing-text');
265
358
  const unknownResult = await executeMemoryCommand(session, 'unknown');
@@ -7,13 +7,29 @@ import {
7
7
  import { executeMemoryCommand } from './memory-command.js';
8
8
 
9
9
  import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
10
- import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-transport';
10
+ import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
11
+
12
+ /**
13
+ * Model-invocable: remembering and recalling project conventions is the model's own work. The
14
+ * subcommand flags (in `buildMemoryCommandSubcommands`) keep `approve`/`reject` user-only, because
15
+ * pending candidates exist so the user reviews what the model proposed to remember.
16
+ */
17
+ const MEMORY_COMMAND_MODEL_DESCRIPTION =
18
+ 'Read and write this project’s durable memory. Use `list` or `show [topic]` to look up stored ' +
19
+ 'conventions, preferences or references before asking the user again; `add <type> <topic> <text>` ' +
20
+ 'to save a durable preference, project convention, feedback item or reference worth reusing ' +
21
+ 'across sessions (never secrets, credentials or transient facts); `pending` to see candidates ' +
22
+ 'awaiting the user’s review; `used` to report which memory items informed this turn. Bare lists ' +
23
+ 'topics. Returns the requested topics or entries, or a confirmation of what was saved. Approving ' +
24
+ 'or rejecting a pending candidate is the user’s review: suggest `/memory approve <id>` or ' +
25
+ '`/memory reject <id>`.';
11
26
 
12
27
  export function createMemoryCommandEntry(): ICommand {
13
28
  return {
14
29
  name: 'memory',
15
30
  displayName: 'Memory',
16
31
  description: MEMORY_COMMAND_DESCRIPTION,
32
+ modelDescription: MEMORY_COMMAND_MODEL_DESCRIPTION,
17
33
  source: 'memory',
18
34
  argumentHint: MEMORY_COMMAND_ARGUMENT_HINT,
19
35
  modelInvocable: true,
@@ -28,6 +44,7 @@ function createMemorySystemCommand(): ISystemCommand {
28
44
  name: entry.name,
29
45
  displayName: entry.displayName,
30
46
  description: entry.description,
47
+ ...(entry.modelDescription !== undefined ? { modelDescription: entry.modelDescription } : {}),
31
48
  requiresPermission: false,
32
49
  userInvocable: true,
33
50
  modelInvocable: true,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  MEMORY_COMMAND_USAGE,
3
+ approvePendingMemoryCandidate,
3
4
  createCommandMemoryStores,
4
5
  hasSensitiveCommandMemoryContent,
5
6
  isCommandMemoryType,
@@ -9,11 +10,12 @@ import {
9
10
 
10
11
  import type {
11
12
  IAppendMemoryInput,
12
- ICommandHostContext,
13
- ICommandPendingMemoryStore,
14
- ICommandProjectMemoryStore,
13
+ ICommandHostMemory,
14
+ ICommandHostWorkspace,
15
+ IMemoryStore,
15
16
  } from '@robota-sdk/agent-framework';
16
- import type { ICommandResult, IMemoryEvent } from '@robota-sdk/agent-interface-transport';
17
+ import type { ICommandResult } from '@robota-sdk/agent-interface-command';
18
+ import type { IMemoryEvent } from '@robota-sdk/agent-interface-session';
17
19
 
18
20
  const SUBCOMMAND_INDEX = 0;
19
21
  const TYPE_INDEX = 1;
@@ -34,8 +36,8 @@ function formatError(error: Error | string): ICommandResult {
34
36
  };
35
37
  }
36
38
 
37
- function formatList(store: ICommandProjectMemoryStore): ICommandResult {
38
- const summary = store.list();
39
+ async function formatList(store: IMemoryStore): Promise<ICommandResult> {
40
+ const summary = await store.list();
39
41
  const topics =
40
42
  summary.topics.length > 0
41
43
  ? summary.topics.map((topic) => `- ${topic.name}: ${topic.path}`).join('\n')
@@ -57,9 +59,9 @@ function formatList(store: ICommandProjectMemoryStore): ICommandResult {
57
59
  };
58
60
  }
59
61
 
60
- function formatShow(store: ICommandProjectMemoryStore, topic?: string): ICommandResult {
62
+ async function formatShow(store: IMemoryStore, topic?: string): Promise<ICommandResult> {
61
63
  if (!topic || topic === 'index') {
62
- const memory = store.loadStartupMemory();
64
+ const memory = await store.loadStartupMemory();
63
65
  return {
64
66
  message: memory.content || '(empty memory index)',
65
67
  success: true,
@@ -71,7 +73,7 @@ function formatShow(store: ICommandProjectMemoryStore, topic?: string): ICommand
71
73
  };
72
74
  }
73
75
 
74
- const content = store.readTopic(topic);
76
+ const content = await store.readTopic(topic);
75
77
  return {
76
78
  message: content || `(empty memory topic: ${topic})`,
77
79
  success: true,
@@ -88,8 +90,8 @@ function parseAdd(args: readonly string[]): IAppendMemoryInput | undefined {
88
90
  return { type, topic, text };
89
91
  }
90
92
 
91
- function formatPending(store: ICommandPendingMemoryStore): ICommandResult {
92
- const records = store.list('pending');
93
+ async function formatPending(store: IMemoryStore): Promise<ICommandResult> {
94
+ const records = await store.listPending('pending');
93
95
  const lines =
94
96
  records.length > 0
95
97
  ? records.map(
@@ -105,21 +107,18 @@ function formatPending(store: ICommandPendingMemoryStore): ICommandResult {
105
107
  };
106
108
  }
107
109
 
108
- function recordEvent(context: ICommandHostContext, event: Omit<IMemoryEvent, 'at'>): void {
110
+ function recordEvent(context: ICommandHostMemory, event: Omit<IMemoryEvent, 'at'>): void {
109
111
  recordCommandMemoryEvent(context, event);
110
112
  }
111
113
 
112
- function approvePending(
113
- context: ICommandHostContext,
114
- pendingStore: ICommandPendingMemoryStore,
115
- memoryStore: ICommandProjectMemoryStore,
114
+ async function approvePending(
115
+ context: ICommandHostMemory,
116
+ store: IMemoryStore,
116
117
  id: string | undefined,
117
- ): ICommandResult {
118
+ ): Promise<ICommandResult> {
118
119
  if (!id) return usage();
119
120
  try {
120
- const approved = pendingStore.mark(id, 'approved', 'approved-by-user');
121
- const saved = memoryStore.append(approved);
122
- const record = pendingStore.mark(id, 'saved', 'approved-and-saved');
121
+ const { record, saved } = await approvePendingMemoryCandidate(store, id);
123
122
  recordEvent(context, {
124
123
  type: 'memory_candidate_approved',
125
124
  candidateId: record.id,
@@ -150,14 +149,14 @@ function approvePending(
150
149
  }
151
150
  }
152
151
 
153
- function rejectPending(
154
- context: ICommandHostContext,
155
- pendingStore: ICommandPendingMemoryStore,
152
+ async function rejectPending(
153
+ context: ICommandHostMemory,
154
+ store: IMemoryStore,
156
155
  id: string | undefined,
157
- ): ICommandResult {
156
+ ): Promise<ICommandResult> {
158
157
  if (!id) return usage();
159
158
  try {
160
- const record = pendingStore.mark(id, 'rejected', 'rejected-by-user');
159
+ const record = await store.markPending(id, 'rejected', 'rejected-by-user');
161
160
  recordEvent(context, {
162
161
  type: 'memory_candidate_rejected',
163
162
  candidateId: record.id,
@@ -174,7 +173,7 @@ function rejectPending(
174
173
  }
175
174
  }
176
175
 
177
- function formatUsed(context: ICommandHostContext): ICommandResult {
176
+ function formatUsed(context: ICommandHostMemory): ICommandResult {
178
177
  const references = listCommandUsedMemoryReferences(context);
179
178
  const lines =
180
179
  references.length > 0
@@ -191,20 +190,21 @@ function formatUsed(context: ICommandHostContext): ICommandResult {
191
190
  };
192
191
  }
193
192
 
194
- export function executeMemoryCommand(
195
- context: ICommandHostContext,
193
+ export async function executeMemoryCommand(
194
+ context: ICommandHostMemory & ICommandHostWorkspace,
196
195
  rawArgs: string,
197
- ): ICommandResult {
196
+ ): Promise<ICommandResult> {
198
197
  const args = rawArgs.trim().split(/\s+/).filter(Boolean);
199
198
  const subcommand = args[SUBCOMMAND_INDEX] ?? 'list';
200
- const stores = createCommandMemoryStores(context);
201
-
202
- if (subcommand === 'list') return formatList(stores.project);
203
- if (subcommand === 'show') return formatShow(stores.project, args[TYPE_INDEX]);
204
- if (subcommand === 'pending') return formatPending(stores.pending);
205
- if (subcommand === 'approve')
206
- return approvePending(context, stores.pending, stores.project, args[TYPE_INDEX]);
207
- if (subcommand === 'reject') return rejectPending(context, stores.pending, args[TYPE_INDEX]);
199
+ // SELFHOST-008 P1R: the single injected durable-memory port (or fs default) — authoritative for all
200
+ // `/memory` operations, so a surface that swaps the store is honored here too (no split-brain).
201
+ const store = createCommandMemoryStores(context);
202
+
203
+ if (subcommand === 'list') return formatList(store);
204
+ if (subcommand === 'show') return formatShow(store, args[TYPE_INDEX]);
205
+ if (subcommand === 'pending') return formatPending(store);
206
+ if (subcommand === 'approve') return approvePending(context, store, args[TYPE_INDEX]);
207
+ if (subcommand === 'reject') return rejectPending(context, store, args[TYPE_INDEX]);
208
208
  if (subcommand === 'used') return formatUsed(context);
209
209
  if (subcommand === 'add') {
210
210
  const input = parseAdd(args);
@@ -215,7 +215,7 @@ export function executeMemoryCommand(
215
215
  success: false,
216
216
  };
217
217
  }
218
- const result = stores.project.append(input);
218
+ const result = await store.append(input);
219
219
  return {
220
220
  message: result.deduplicated
221
221
  ? `${input.type} memory already exists in ${result.topicPath}`