@bevel-software/platform-core-backend 0.34.0 → 0.35.0

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 (179) hide show
  1. package/agent-guide/access-control.md +146 -300
  2. package/agent-guide/conventions.md +23 -25
  3. package/agent-guide/directory-structure.md +72 -120
  4. package/agent-guide/drafts-and-change-requests.md +92 -0
  5. package/agent-guide/for-git-users.md +47 -0
  6. package/agent-guide/glossary.md +36 -0
  7. package/agent-guide/how-agents-find-things.md +49 -0
  8. package/agent-guide/html-views.md +16 -11
  9. package/agent-guide/new-knowledge-base.md +9 -8
  10. package/agent-guide/skills.md +67 -42
  11. package/agent-guide/start-here.md +65 -0
  12. package/agent-guide/tool-manuals.md +132 -154
  13. package/agent-guide/tools.md +67 -0
  14. package/agent-guide/where-a-new-file-goes.md +45 -28
  15. package/dist/core/create-core-server.js +1 -1
  16. package/dist/core/create-core-server.js.map +1 -1
  17. package/dist/core/create-core-services.d.ts +6 -0
  18. package/dist/core/create-core-services.d.ts.map +1 -1
  19. package/dist/core/create-core-services.js +7 -4
  20. package/dist/core/create-core-services.js.map +1 -1
  21. package/dist/modules/access/access-control.interface.d.ts +22 -0
  22. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.d.ts +3 -1
  24. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  25. package/dist/modules/access/access-control.service.js +75 -0
  26. package/dist/modules/access/access-control.service.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts.map +1 -1
  28. package/dist/modules/access/access.routes.js +23 -4
  29. package/dist/modules/access/access.routes.js.map +1 -1
  30. package/dist/modules/access/index.d.ts +1 -1
  31. package/dist/modules/access/index.d.ts.map +1 -1
  32. package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
  33. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  34. package/dist/modules/agent-guide/agent-guide.js +103 -13
  35. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  36. package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  38. package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
  39. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  40. package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
  41. package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
  42. package/dist/modules/agent-guide/guide-budget.js +67 -0
  43. package/dist/modules/agent-guide/guide-budget.js.map +1 -0
  44. package/dist/modules/agent-guide/index.d.ts +2 -1
  45. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  46. package/dist/modules/agent-guide/index.js +2 -1
  47. package/dist/modules/agent-guide/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  49. package/dist/modules/agent-instructions/compose.js +4 -0
  50. package/dist/modules/agent-instructions/compose.js.map +1 -1
  51. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  52. package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
  53. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  54. package/dist/modules/code-mode/code-mode.tool.js +2 -2
  55. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  56. package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
  57. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  58. package/dist/modules/connection-probe/connection-probe.service.js +1 -38
  59. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  60. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  61. package/dist/modules/mcp/mcp.service.js +6 -35
  62. package/dist/modules/mcp/mcp.service.js.map +1 -1
  63. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
  64. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  65. package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
  66. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  67. package/dist/modules/secrets-vault/index.d.ts +1 -1
  68. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  69. package/dist/modules/secrets-vault/index.js +1 -1
  70. package/dist/modules/secrets-vault/index.js.map +1 -1
  71. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
  72. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  73. package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
  74. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  75. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  76. package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
  77. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  78. package/dist/modules/settings/oidc-check.d.ts.map +1 -1
  79. package/dist/modules/settings/oidc-check.js +5 -17
  80. package/dist/modules/settings/oidc-check.js.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +1 -1
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-helpers/tool-def.js +1 -1
  84. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  85. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
  86. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  87. package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
  88. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
  92. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  93. package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
  94. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  95. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
  96. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
  98. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  99. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  100. package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
  101. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  102. package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
  103. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
  104. package/dist/modules/workspace/agent-access.gate.js +1 -1
  105. package/dist/modules/workspace/agent-access.gate.js.map +1 -1
  106. package/dist/modules/workspace/workspace.tools.d.ts +3 -2
  107. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.tools.js +71 -21
  109. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  110. package/dist/shared/oauth-token-error.d.ts +23 -0
  111. package/dist/shared/oauth-token-error.d.ts.map +1 -0
  112. package/dist/shared/oauth-token-error.js +47 -0
  113. package/dist/shared/oauth-token-error.js.map +1 -0
  114. package/dist/shared/read-capped.d.ts +19 -0
  115. package/dist/shared/read-capped.d.ts.map +1 -0
  116. package/dist/shared/read-capped.js +45 -0
  117. package/dist/shared/read-capped.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/__tests__/hexis-catalog.ts +144 -0
  120. package/src/__tests__/real-disk.ts +21 -0
  121. package/src/__tests__/tokens.ts +19 -0
  122. package/src/__tests__/tool-manuals.ts +32 -0
  123. package/src/__tests__/tool-wiring.ts +25 -0
  124. package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
  125. package/src/core/create-core-server.ts +1 -1
  126. package/src/core/create-core-services.ts +13 -3
  127. package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
  128. package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
  129. package/src/modules/access/access-control.interface.ts +26 -0
  130. package/src/modules/access/access-control.service.ts +92 -0
  131. package/src/modules/access/access.routes.ts +23 -4
  132. package/src/modules/access/index.ts +1 -1
  133. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
  134. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
  135. package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
  136. package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
  137. package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
  138. package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
  139. package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
  140. package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
  141. package/src/modules/agent-guide/agent-guide.ts +120 -13
  142. package/src/modules/agent-guide/guide-budget.ts +73 -0
  143. package/src/modules/agent-guide/index.ts +12 -0
  144. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
  145. package/src/modules/agent-instructions/compose.ts +4 -0
  146. package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
  147. package/src/modules/code-mode/code-mode.tool.ts +2 -2
  148. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
  149. package/src/modules/connection-probe/connection-probe.service.ts +1 -34
  150. package/src/modules/mcp/mcp.service.ts +5 -35
  151. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
  152. package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
  153. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
  154. package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
  155. package/src/modules/secrets-vault/index.ts +1 -1
  156. package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
  157. package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
  158. package/src/modules/settings/oidc-check.ts +5 -18
  159. package/src/modules/skills/skills.tools.ts +1 -1
  160. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
  161. package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
  162. package/src/modules/tool-helpers/tool-def.ts +1 -1
  163. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
  164. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
  165. package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
  166. package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
  167. package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
  168. package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
  169. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
  170. package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
  171. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
  172. package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
  173. package/src/modules/workspace/agent-access.gate.ts +1 -1
  174. package/src/modules/workspace/workspace.tools.ts +74 -21
  175. package/src/shared/__tests__/read-capped.test.ts +68 -0
  176. package/src/shared/oauth-token-error.ts +46 -0
  177. package/src/shared/read-capped.ts +40 -0
  178. package/agent-guide/finding-things.md +0 -7
  179. package/agent-guide/introduction.md +0 -27
@@ -3,15 +3,14 @@ import type { AddressInfo } from 'node:net';
3
3
  import express from 'express';
4
4
  import { describe, it, expect, afterEach, vi } from 'vitest';
5
5
 
6
- import type { IAccessControl, ProspectiveHolders } from '../access-control.interface.js';
7
- import type { WorkspaceService } from '../../workspace/workspace.service.js';
8
- import type { AuthService } from '../../auth/auth.service.js';
9
- import type { WorkflowService } from '../../workflow/workflow.service.js';
10
- import type { WorkflowEventBus } from '../../workflow/event-bus.js';
11
- import type { Database } from '../../database/connection.js';
6
+ import type { HolderChanges, IAccessControl, ProspectiveHolders } from '../access-control.interface.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
+ import type { AuthService } from '../../auth/index.js';
9
+ import type { WorkflowEventBus, WorkflowService } from '../../workflow/index.js';
10
+ import type { Database } from '../../database/index.js';
12
11
  import { createAccessRoutes } from '../access.routes.js';
13
12
  import { testKbContext } from '../../../__tests__/kb-context.js';
14
- import { NodeFs } from '../../kb-fs/node-fs.js';
13
+ import { realDisk } from '../../../__tests__/real-disk.js';
15
14
 
16
15
  /**
17
16
  * HTTP contract for `GET /access/prospective` — what the move confirmation
@@ -41,17 +40,28 @@ const HOLDERS: ProspectiveHolders = {
41
40
  },
42
41
  };
43
42
 
43
+ /** What the resolver judges changed: Engineering loses editing, Ali gains opening. */
44
+ const CHANGES: HolderChanges = {
45
+ lose: [{ kind: 'group', name: 'Engineering', verb: 'write' }],
46
+ gain: [{ kind: 'person', name: 'ali@bevel.software', email: 'ali@bevel.software', verb: 'read' }],
47
+ };
48
+
44
49
  interface Harness {
45
50
  server: Server;
46
51
  baseUrl: string;
47
52
  canRead: ReturnType<typeof vi.fn>;
48
53
  prospectiveHolders: ReturnType<typeof vi.fn>;
54
+ prospectiveHolderChanges: ReturnType<typeof vi.fn>;
49
55
  }
50
56
 
51
- async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
52
- const canRead = vi.fn(async () => opts.canRead ?? true);
57
+ /** `canRead` answers `opts.canRead` for every path, or per path through `opts.readable`. */
58
+ async function makeHarness(opts: { canRead?: boolean; readable?: (path: string) => boolean } = {}): Promise<Harness> {
59
+ const canRead = vi.fn(async (_ws: string, _email: string, path: string) =>
60
+ opts.readable ? opts.readable(path) : (opts.canRead ?? true),
61
+ );
53
62
  const prospectiveHolders = vi.fn(async () => HOLDERS);
54
- const accessControl = { canRead, prospectiveHolders } as unknown as IAccessControl;
63
+ const prospectiveHolderChanges = vi.fn(async () => CHANGES);
64
+ const accessControl = { canRead, prospectiveHolders, prospectiveHolderChanges } as unknown as IAccessControl;
55
65
 
56
66
  const app = express();
57
67
  app.use(express.json());
@@ -70,7 +80,7 @@ async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
70
80
  { emit: vi.fn() } as unknown as WorkflowEventBus,
71
81
  {} as unknown as Database,
72
82
  testKbContext({ kbDirName: KB }),
73
- new NodeFs(),
83
+ realDisk(),
74
84
  ),
75
85
  );
76
86
 
@@ -78,7 +88,7 @@ async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
78
88
  const s = app.listen(0, () => resolve(s));
79
89
  });
80
90
  const addr = server.address() as AddressInfo;
81
- return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canRead, prospectiveHolders };
91
+ return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canRead, prospectiveHolders, prospectiveHolderChanges };
82
92
  }
83
93
 
84
94
  function close(s: Server): Promise<void> {
@@ -110,6 +120,13 @@ describe('GET /access/prospective', () => {
110
120
  'Knowledge/Legal/contract.md',
111
121
  'Knowledge/Sales/contract.md',
112
122
  );
123
+ // The resolver's own changes ride along: the one answer the move dialog
124
+ // renders, and the same the agent's `move_file` gives.
125
+ expect(h.prospectiveHolderChanges).toHaveBeenCalledWith(
126
+ WS,
127
+ 'Knowledge/Legal/contract.md',
128
+ 'Knowledge/Sales/contract.md',
129
+ );
113
130
  expect(await res.json()).toEqual({
114
131
  before: {
115
132
  read: [{ kind: 'group', name: 'Engineering' }],
@@ -122,9 +139,32 @@ describe('GET /access/prospective', () => {
122
139
  ],
123
140
  write: [],
124
141
  },
142
+ changes: CHANGES,
125
143
  });
126
144
  });
127
145
 
146
+ it('refuses a caller who cannot read the destination folder, resolving nothing: the lists and changes are that folder\'s rules read back', async () => {
147
+ h = await makeHarness({ readable: (path) => path !== 'Knowledge/Sales' });
148
+
149
+ const res = await get(
150
+ `from=${encodeURIComponent('Knowledge/Legal/contract.md')}&toDir=${encodeURIComponent('Knowledge/Sales')}`,
151
+ );
152
+
153
+ expect(res.status).toBe(403);
154
+ expect(await res.json()).toEqual({ error: 'You do not have access to this folder.' });
155
+ expect(h.canRead).toHaveBeenCalledWith(WS, USER.email, 'Knowledge/Sales');
156
+ expect(h.prospectiveHolders).not.toHaveBeenCalled();
157
+ expect(h.prospectiveHolderChanges).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('asks the read gate about the repo root when the destination is the root', async () => {
161
+ h = await makeHarness();
162
+
163
+ await get(`from=${encodeURIComponent('Knowledge/Legal/contract.md')}&toDir=`);
164
+
165
+ expect(h.canRead).toHaveBeenCalledWith(WS, USER.email, '');
166
+ });
167
+
128
168
  it('strips the workspace-relative kb prefix from both ends', async () => {
129
169
  h = await makeHarness();
130
170
 
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import { realDisk } from '../../../__tests__/real-disk.js';
7
+ import type { WorkspaceService } from '../../workspace/index.js';
8
+ import { AccessControlService } from '../access-control.service.js';
9
+
10
+ /**
11
+ * `prospectiveHolderChanges`: who loses and who gains opening or editing a
12
+ * file or folder in a move, over a real on-disk tree. The holder lists name
13
+ * grants, so these tests are about the cases a list comparison gets wrong: a
14
+ * group still covered by `everyone` where it lands, and someone a rule there
15
+ * denies by name, even when their only access before was `everyone`.
16
+ */
17
+
18
+ const KB_DIR = 'knowledge-base';
19
+ const rules = (body: string) => `---\n${body}---\n`;
20
+
21
+ describe('AccessControlService.prospectiveHolderChanges', () => {
22
+ let root: string;
23
+ let repo: string;
24
+ const workspaceId = 'ws-holder-changes';
25
+
26
+ beforeEach(async () => {
27
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-holder-changes-'));
28
+ repo = path.join(root, workspaceId, KB_DIR);
29
+ });
30
+ afterEach(async () => {
31
+ if (root) await fs.rm(root, { recursive: true, force: true });
32
+ });
33
+
34
+ async function write(files: Record<string, string>): Promise<void> {
35
+ for (const [rel, contents] of Object.entries(files)) {
36
+ const abs = path.join(repo, rel);
37
+ await fs.mkdir(path.dirname(abs), { recursive: true });
38
+ await fs.writeFile(abs, contents);
39
+ }
40
+ }
41
+
42
+ const service = () =>
43
+ new AccessControlService(
44
+ {
45
+ getWorkspacePath: async () => path.join(root, workspaceId),
46
+ ensureRemotesFetched: async () => undefined,
47
+ } as unknown as WorkspaceService,
48
+ KB_DIR,
49
+ realDisk(),
50
+ );
51
+
52
+ /** Everyone reads; Support and Operations each edit their own folder. */
53
+ const BASE = {
54
+ 'roles.yaml': 'roles:\n Admin:\n - admin@x.io\n',
55
+ 'groups.yaml': 'groups:\n Support:\n - bea@x.io\n Operations:\n - jonas@x.io\n',
56
+ 'access.md': rules('read:\n - everyone\n'),
57
+ 'Support/access.md': rules('write:\n - Support\n'),
58
+ 'Support/Runbooks/Warranty claim.md': '# Warranty claim\n',
59
+ 'Operations/access.md': rules('write:\n - Operations\n'),
60
+ };
61
+
62
+ it('a folder handed to another team: the old team loses editing, the new one gains it, and nobody loses opening it', async () => {
63
+ await write(BASE);
64
+ const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Operations/Runbooks');
65
+ expect(changes).toEqual({
66
+ lose: [{ kind: 'group', name: 'Support', verb: 'write' }],
67
+ gain: [{ kind: 'group', name: 'Operations', verb: 'write' }],
68
+ });
69
+ });
70
+
71
+ it('a person the destination denies by name loses access, though everyone may still read there', async () => {
72
+ await write({
73
+ ...BASE,
74
+ 'Team/access.md': rules('write:\n - Ana <ana@x.io>\n'),
75
+ 'Team/plan.md': '# Plan\n',
76
+ 'Closed/access.md': rules('read:\n - everyone\n - deny Ana <ana@x.io>\n'),
77
+ });
78
+ const changes = await service().prospectiveHolderChanges(workspaceId, 'Team/plan.md', 'Closed/plan.md');
79
+ expect(changes.lose).toEqual(
80
+ expect.arrayContaining([
81
+ { kind: 'person', name: 'ana@x.io', email: 'ana@x.io', verb: 'read' },
82
+ { kind: 'person', name: 'ana@x.io', email: 'ana@x.io', verb: 'write' },
83
+ ]),
84
+ );
85
+ });
86
+
87
+ it('a person whose only access was everyone, and whom the destination denies by name, loses it', async () => {
88
+ await write({
89
+ ...BASE,
90
+ 'Closed/access.md': rules('read:\n - everyone\n - deny Leo <leo@x.io>\n'),
91
+ });
92
+ const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Closed/Runbooks');
93
+ expect(changes.lose).toContainEqual({ kind: 'person', name: 'leo@x.io', email: 'leo@x.io', verb: 'read' });
94
+ });
95
+
96
+ it('a group whose only read was everyone, and which the destination denies, loses it', async () => {
97
+ await write({
98
+ ...BASE,
99
+ 'Closed/access.md': rules('read:\n - everyone\n - deny Operations\n'),
100
+ });
101
+ const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Closed/Runbooks');
102
+ expect(changes.lose).toContainEqual({ kind: 'group', name: 'Operations', verb: 'read' });
103
+ });
104
+
105
+ it('a group the destination denies loses opening it, though everyone may still read there', async () => {
106
+ await write({
107
+ ...BASE,
108
+ 'Closed/access.md': rules('read:\n - everyone\n - deny Support\n'),
109
+ });
110
+ const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Closed/Runbooks');
111
+ expect(changes.lose).toEqual(
112
+ expect.arrayContaining([
113
+ { kind: 'group', name: 'Support', verb: 'read' },
114
+ { kind: 'group', name: 'Support', verb: 'write' },
115
+ ]),
116
+ );
117
+ });
118
+
119
+ it('a rename that carries its own rules changes nobody', async () => {
120
+ await write({
121
+ ...BASE,
122
+ 'Sales/access.md': rules('read:\n - Support\nwrite:\n - Support\n'),
123
+ 'Sales/deal.md': '# Deal\n',
124
+ });
125
+ expect(await service().prospectiveHolderChanges(workspaceId, 'Sales', 'Revenue')).toEqual({ lose: [], gain: [] });
126
+ });
127
+ });
@@ -92,6 +92,17 @@ export type PathHolders = { read: HolderList; write: HolderList };
92
92
  */
93
93
  export type ProspectiveHolders = { before: PathHolders; after: PathHolders };
94
94
 
95
+ /** One principal that loses or gains one verb in a move. A person is named by their address when they have no display name. */
96
+ export type HolderChange = {
97
+ kind: 'group' | 'role' | 'plugin' | 'person';
98
+ name: string;
99
+ email?: string;
100
+ verb: 'read' | 'write';
101
+ };
102
+
103
+ /** Who loses and who gains opening or editing a file or folder in a move — see {@link IAccessControl.prospectiveHolderChanges}. */
104
+ export type HolderChanges = { lose: HolderChange[]; gain: HolderChange[] };
105
+
95
106
  /** One caller's own verdict per access verb on one path. */
96
107
  export type PathVerbs = { read: boolean; write: boolean; download: boolean; owner: boolean };
97
108
 
@@ -420,6 +431,21 @@ export interface IAccessControl {
420
431
  toPath: string,
421
432
  ): Promise<ProspectiveHolders>;
422
433
 
434
+ /**
435
+ * Who loses and who gains `read` or `write` on one file or folder if a move
436
+ * puts it at `toPath`, over the same relocation as
437
+ * {@link IAccessControl.prospectiveHolders}. A holder on one side loses (or
438
+ * gains) the verb only when it does not hold it on the other side: the
439
+ * lists name grants, so a group no rule names at the destination may still
440
+ * read there through `everyone`, and one a rule denies there may not.
441
+ * Writes nothing and moves nothing.
442
+ */
443
+ prospectiveHolderChanges(
444
+ workspaceId: string,
445
+ fromPath: string,
446
+ toPath: string,
447
+ ): Promise<HolderChanges>;
448
+
423
449
  /**
424
450
  * The CALLER'S OWN `{ read, write, download, owner }` at `toPath` as they
425
451
  * will be once `fromPath` has been moved (`sourceRemains: false`, the
@@ -22,6 +22,8 @@ import type {
22
22
  GrantSources,
23
23
  PathHolders,
24
24
  PathVerbs,
25
+ HolderChange,
26
+ HolderChanges,
25
27
  ProspectiveHolders,
26
28
  ResolvedPrincipal,
27
29
  } from './access-control.interface.js';
@@ -1354,6 +1356,70 @@ function eligibleHoldersResolved(
1354
1356
  return { principals, roles, users };
1355
1357
  }
1356
1358
 
1359
+ /** One side of a move: the access model, the path, and the rules the file carries there. */
1360
+ type MoveSide = { model: AccessModel; path: string; own: OwnEntries | null };
1361
+
1362
+ /** The key set behind one entry of an eligible-holders list, read back from its name and kind. */
1363
+ function keysOfHolder(model: AccessModel, holder: ResolvedPrincipal): ReadonlySet<string> {
1364
+ for (const [canonical, record] of model.roles.byCanonical) {
1365
+ if (record.kind === holder.kind && record.displayName === holder.name) return principalKeysOfToken(model, canonical);
1366
+ }
1367
+ return principalKeysOfToken(model, canonicalRoleName(holder.name));
1368
+ }
1369
+
1370
+ /**
1371
+ * Who loses and who gains `verb` between the two sides of a move. The
1372
+ * candidates are every principal and person a rule on either side names for
1373
+ * the verb, as a grant or as a denial, and every holder either side lists;
1374
+ * each is put to both sides' resolvers as the gates would put it. The holder
1375
+ * lists alone are not enough: they name grants, so they miss a group still
1376
+ * covered by `everyone`, and they cannot show someone whose only access was
1377
+ * `everyone` and whom a rule on the far side denies by name.
1378
+ */
1379
+ function holderChangesFor(verb: 'read' | 'write', here: MoveSide, there: MoveSide): HolderChanges {
1380
+ const collectives = new Map<string, { holder: ResolvedPrincipal; keys: ReadonlySet<string> }>();
1381
+ const people = new Set<string>();
1382
+ const addCollective = (holder: ResolvedPrincipal, keys: ReadonlySet<string>) => {
1383
+ const id = `${holder.kind}\0${holder.name.toLowerCase()}`;
1384
+ if (!collectives.has(id)) collectives.set(id, { holder, keys });
1385
+ };
1386
+ for (const side of [here, there]) {
1387
+ const held = eligibleHoldersResolved(side.model, verb, side.path, side.own);
1388
+ for (const holder of held.principals) addCollective(holder, keysOfHolder(side.model, holder));
1389
+ for (const user of held.users) people.add(user.email);
1390
+ const { byRole, byEmail } = resolveAtPath(side.model, verb, side.path, side.own);
1391
+ for (const token of byRole.keys()) {
1392
+ const record = side.model.roles.byCanonical.get(token);
1393
+ addCollective(
1394
+ { name: record ? record.displayName : token, kind: record?.kind ?? 'role' },
1395
+ principalKeysOfToken(side.model, token),
1396
+ );
1397
+ }
1398
+ for (const email of byEmail.keys()) people.add(email);
1399
+ }
1400
+ const lose: HolderChange[] = [];
1401
+ const gain: HolderChange[] = [];
1402
+ const record = (had: boolean, has: boolean, change: HolderChange) => {
1403
+ if (had && !has) lose.push(change);
1404
+ if (!had && has) gain.push(change);
1405
+ };
1406
+ for (const { holder, keys } of collectives.values()) {
1407
+ record(
1408
+ hasPermissionForKeys(here.model, verb, keys, here.path, here.own),
1409
+ hasPermissionForKeys(there.model, verb, keys, there.path, there.own),
1410
+ { kind: holder.kind, name: holder.name, verb },
1411
+ );
1412
+ }
1413
+ for (const email of [...people].sort()) {
1414
+ record(
1415
+ hasPermissionResolved(here.model, verb, email, here.path, here.own),
1416
+ hasPermissionResolved(there.model, verb, email, there.path, there.own),
1417
+ { kind: 'person', name: email, email, verb },
1418
+ );
1419
+ }
1420
+ return { lose, gain };
1421
+ }
1422
+
1357
1423
  /**
1358
1424
  * Every individual the model names: `roles.yaml` members, emails named inline
1359
1425
  * at this path's scope, and the deployment owner (bound by every rule that
@@ -1928,6 +1994,32 @@ export class AccessControlService implements IAccessControl {
1928
1994
  };
1929
1995
  }
1930
1996
 
1997
+ /** Who loses and who gains `read` or `write` in a move (see the interface). */
1998
+ async prospectiveHolderChanges(
1999
+ workspaceId: string,
2000
+ fromPath: string,
2001
+ toPath: string,
2002
+ ): Promise<HolderChanges> {
2003
+ const model = await this.loadModel(workspaceId);
2004
+ const repoDir = await this.repoDir(workspaceId);
2005
+ const { isFolder, ownHere, ownThere, landing } =
2006
+ await this.relocationSubject(repoDir, fromPath, toPath);
2007
+ const here: MoveSide = { model, path: fromPath, own: ownHere };
2008
+ const there: MoveSide = {
2009
+ model: withRelocatedAccessFiles(model, fromPath, toPath, { sourceRemains: false, sourceIsFolder: isFolder, landing }),
2010
+ path: toPath,
2011
+ own: ownThere,
2012
+ };
2013
+ const lose: HolderChange[] = [];
2014
+ const gain: HolderChange[] = [];
2015
+ for (const verb of ['read', 'write'] as const) {
2016
+ const changes = holderChangesFor(verb, here, there);
2017
+ lose.push(...changes.lose);
2018
+ gain.push(...changes.gain);
2019
+ }
2020
+ return { lose, gain };
2021
+ }
2022
+
1931
2023
  /**
1932
2024
  * The caller's own verbs at `toPath` as they will be once `fromPath` has
1933
2025
  * been moved or copied there (see the interface).
@@ -414,9 +414,20 @@ export function createAccessRoutes(
414
414
  * nothing is moved.
415
415
  *
416
416
  * Gated like the sibling `overrides` route rather than the permissive
417
- * `GET /access`: the caller must resolve read on the thing being moved. The
418
- * lists name people, and someone who cannot see it has no business
419
- * learning who can.
417
+ * `GET /access`: the caller must resolve read on the thing being moved AND
418
+ * on the destination folder. The lists name people, and someone who cannot
419
+ * see a thing has no business learning who can; the `after` lists and the
420
+ * `changes` are the destination folder's rules read back (whom it grants,
421
+ * whom it denies by name), so a folder the caller may not open tells them
422
+ * nothing either.
423
+ *
424
+ * `changes` is who loses and who gains `read` or `write` in the move, as the
425
+ * resolver judges each candidate on both sides
426
+ * (`prospectiveHolderChanges`) — the one answer the move dialog and the
427
+ * agent's `move_file` both show. The two lists are not that answer: they
428
+ * name grants, so a writer leaving a folder everyone reads shows as losing
429
+ * `read` it keeps, and a person the destination denies by name is on
430
+ * neither list.
420
431
  */
421
432
  router.get('/workspace/:id/access/prospective', async (req, res) => {
422
433
  const user = await requireUser(req, res);
@@ -445,13 +456,21 @@ export function createAccessRoutes(
445
456
  res.status(403).json({ error: 'You do not have access to this file.' });
446
457
  return;
447
458
  }
459
+ if (!(await accessControl.canRead(req.params.id, user.email, toDir))) {
460
+ res.status(403).json({ error: 'You do not have access to this folder.' });
461
+ return;
462
+ }
448
463
 
449
464
  const name = from.slice(from.lastIndexOf('/') + 1);
450
465
  const to = toDir ? `${toDir}/${name}` : name;
451
- const { before, after } = await accessControl.prospectiveHolders(req.params.id, from, to);
466
+ const [{ before, after }, changes] = await Promise.all([
467
+ accessControl.prospectiveHolders(req.params.id, from, to),
468
+ accessControl.prospectiveHolderChanges(req.params.id, from, to),
469
+ ]);
452
470
  res.json({
453
471
  before: { read: holderPrincipals(before.read), write: holderPrincipals(before.write) },
454
472
  after: { read: holderPrincipals(after.read), write: holderPrincipals(after.write) },
473
+ changes,
455
474
  });
456
475
  } catch (err) {
457
476
  const { status, body } = toHttpError(err);
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export type { IAccessControl } from './access-control.interface.js';
7
7
  // Reached by other modules; exported here so they stop importing the files.
8
- export type { AccessDecisionSource, AccessTargetKind } from './access-control.interface.js';
8
+ export type { AccessDecisionSource, AccessTargetKind, HolderChange, HolderChanges } from './access-control.interface.js';
9
9
  export { accessMdPathForFolder, fileCarriesAccessRules, governingFolderOf } from './access-mutation.service.js';
10
10
  export type { AccessMutationService, IAccessMutation, TargetKind } from './access-mutation.service.js';
11
11
  export { folderTarget, rulesFileFor } from './access-requests.contract.js';
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { mergeGroupsIntoRoles, parseRolesYaml } from '../../access-model/access-grammar.js';
4
- import { sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
3
+ import { mergeGroupsIntoRoles, parseRolesYaml } from '../../access-model/index.js';
4
+ import { sharedFileRulesSection } from '../../agent-instructions/index.js';
5
5
  import {
6
6
  CORE_SECTION_IDS,
7
7
  PLATFORM_GUIDE_SEPARATOR,
@@ -9,6 +9,7 @@ import {
9
9
  agentGuideSections,
10
10
  composeAgentGuide,
11
11
  coreAgentGuideSections,
12
+ guideStartText,
12
13
  isAgentGuidePath,
13
14
  isManagedGuide,
14
15
  joinGuideSections,
@@ -44,11 +45,8 @@ describe('the guide is composed from the platform\'s sections', () => {
44
45
  const rules = sections.find((s) => s.id === WORKING_WITH_FILES_SECTION_ID)!;
45
46
  expect(rules.body).toBe(sharedFileRulesSection(DEFAULT_KB_LAYOUT));
46
47
  expect(rules.literal).toBe(true);
47
- // Every file section begins with its heading, so the guide reads as one document.
48
- for (const s of sections) {
49
- if (s.id === 'introduction') expect(s.body.startsWith('# Knowledge base')).toBe(true);
50
- else expect(s.body.startsWith('## '), s.id).toBe(true);
51
- }
48
+ // Every section begins with its heading, so the guide reads as one document.
49
+ for (const s of sections) expect(s.body.startsWith('## '), s.id).toBe(true);
52
50
  // The order of the composed guide is the order of the sections — pinned
53
51
  // exactly above by the id list, and by the join test below, which equates
54
52
  // the composed guide with the sections joined.
@@ -72,12 +70,13 @@ describe('the guide is composed from the platform\'s sections', () => {
72
70
  });
73
71
 
74
72
  it('says it is served, not written, and names the one file it is read as', async () => {
75
- const intro = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '# Knowledge base').replace(/\n> ?/g, ' ');
76
- expect(intro).toContain('**This guide is served by the platform.**');
77
- expect(intro).toContain('`get_agent_guide` returns it, whole or one section at a time');
78
- expect(intro).toContain('`read_file` on `AGENTS.md` at the repository root');
79
- expect(intro).toContain('`grep` searches it there too');
80
- expect(intro).toContain('never write this text into it');
73
+ const guide0 = await composeAgentGuide(DEFAULT_KB_LAYOUT);
74
+ const start = section(guide0, '## Start here').replace(/\s+/g, ' ');
75
+ expect(start).toContain('`get_agent_guide({ body: { section: "all" } })` returns the whole guide');
76
+ // By id, not by heading: the section shows an AGENTS.md snippet whose own `##` heading sits in a code fence.
77
+ const finding = (await agentGuideSections(DEFAULT_KB_LAYOUT)).find((s) => s.id === 'how-agents-find-things')!.body.replace(/\s+/g, ' ');
78
+ expect(finding).toContain('`read_file` on `AGENTS.md` returns that file first, then the start of this guide');
79
+ expect(finding).toContain('never copy this guide into either: the platform serves it');
81
80
  // The old header, which proved a file on disk was the platform's, is gone
82
81
  // from the served text — or every read would look like a stale copy.
83
82
  expect(isManagedGuide(await composeAgentGuide(DEFAULT_KB_LAYOUT))).toBe(false);
@@ -95,7 +94,7 @@ describe('the guide is composed from the platform\'s sections', () => {
95
94
  it('serves the guide as sections with titles, the whole being the sections joined', async () => {
96
95
  const sections = await agentGuideSections(DEFAULT_KB_LAYOUT);
97
96
  expect(sections.map((s) => s.id)).toEqual(CORE_SECTION_IDS);
98
- expect(sections.find((s) => s.id === 'introduction')!.title).toBe('Knowledge base');
97
+ expect(sections.find((s) => s.id === 'start-here')!.title).toBe('Start here');
99
98
  expect(sections.find((s) => s.id === 'where-a-new-file-goes')!.title).toBe('Where a new file goes');
100
99
  expect(sections.find((s) => s.id === 'skills')!.title).toContain('Skills (`Skills/');
101
100
  expect(joinGuideSections(sections)).toBe(await composeAgentGuide(DEFAULT_KB_LAYOUT));
@@ -120,13 +119,13 @@ describe('the guide is composed from the platform\'s sections', () => {
120
119
  const hook = (sections: readonly AgentGuideSection[]) => [
121
120
  ...sections
122
121
  .filter((s) => s.id !== 'conventions')
123
- .map((s) => (s.id === 'finding-things' ? { ...s, body: '## Finding things\n\nAsk the graph.\n' } : s)),
122
+ .map((s) => (s.id === 'glossary' ? { ...s, body: '## Words used here\n\nAsk the graph.\n' } : s)),
124
123
  { id: 'knowledge-graph', body: '## Knowledge graph\n\nNodes live under `{{knowledgeBaseDir}}/<Ontology>/Knowledge/`.\n' },
125
124
  ];
126
125
  const guide = await composeAgentGuide({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: 'Docs' }, hook);
127
126
  expect(guide).not.toContain('## Conventions');
128
- expect(section(guide, '## Finding things')).toContain('Ask the graph.');
129
- expect(section(guide, '## Finding things')).not.toContain('grep');
127
+ expect(section(guide, '## Words used here')).toContain('Ask the graph.');
128
+ expect(section(guide, '## Words used here')).not.toContain('Plugin');
130
129
  expect(guide.trimEnd().endsWith('Nodes live under `Docs/<Ontology>/Knowledge/`.')).toBe(true);
131
130
  expect(guide).toContain(sharedFileRulesSection({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: 'Docs' }));
132
131
  // An async hook is awaited, and the layout it is handed is the resolved one.
@@ -138,6 +137,61 @@ describe('the guide is composed from the platform\'s sections', () => {
138
137
  expect(seen).toEqual(['Docs', 'AGENTS.md']);
139
138
  });
140
139
 
140
+ it('routes the start section to a section a distribution adds, when that section says what it answers', async () => {
141
+ const hook = (sections: readonly AgentGuideSection[]) => [
142
+ ...sections,
143
+ { id: 'graph-rules', body: '## Graph rules\n\nNodes carry an id.\n', asks: 'How is a graph node written? Which | fields?' },
144
+ { id: 'quiet', body: '## Quiet\n\nNot routed.\n' },
145
+ // Already in the table: no second row.
146
+ { id: 'tools', body: '## Tools again\n\nA second copy.\n', asks: 'When can I use a tool?' },
147
+ ];
148
+ const start = (await agentGuideSections(DEFAULT_KB_LAYOUT, hook)).find((s) => s.id === 'start-here')!.body;
149
+ const rows = start.split('\n').filter((line) => line.startsWith('|'));
150
+ // After the table's last row, with a `|` in the question escaped.
151
+ expect(rows.at(-1)).toBe('| How is a graph node written? Which \\| fields? | `graph-rules` |');
152
+ expect(start).not.toContain('`quiet`');
153
+ expect(rows.filter((line) => line.endsWith('| `tools` |'))).toHaveLength(1);
154
+ // Core's own guide is left as it was.
155
+ const core = (await agentGuideSections(DEFAULT_KB_LAYOUT)).find((s) => s.id === 'start-here')!.body;
156
+ expect(core).not.toContain('graph-rules');
157
+ });
158
+
159
+ it('serves a distribution\'s own version of a core section in core\'s place, when the hook adds it under the same id', async () => {
160
+ const hook = (sections: readonly AgentGuideSection[]) => [
161
+ ...sections,
162
+ // The enterprise way: its own sections appended, two of them under core ids.
163
+ { id: 'html-views', body: '## Pages, our way\n\nOurs.\n' },
164
+ { id: 'start-here', body: '## Start here\n\nOurs first.\n\n| Question | Section |\n|---|---|\n| Pages? | `html-views` |\n' },
165
+ { id: 'graph', body: '## Graph\n\nNodes.\n', asks: 'How is the graph written?' },
166
+ ];
167
+ const sections = await agentGuideSections(DEFAULT_KB_LAYOUT, hook);
168
+ const core = await agentGuideSections(DEFAULT_KB_LAYOUT);
169
+ // One section per id, each where core's stood, the hook's text in it.
170
+ expect(sections.map((s) => s.id)).toEqual([...core.map((s) => s.id), 'graph']);
171
+ expect(sections.find((s) => s.id === 'html-views')!.body).toBe('## Pages, our way\n\nOurs.');
172
+ const start = sections.find((s) => s.id === 'start-here')!.body;
173
+ expect(start.startsWith('## Start here\n\nOurs first.')).toBe(true);
174
+ // The overriding start section is the one routed to: a row for the added section lands in ITS table.
175
+ expect(start).toContain('| How is the graph written? | `graph` |');
176
+ expect(start).not.toContain('`for-git-users`');
177
+ // The whole guide carries the override once, and never core's text under that id.
178
+ const guide = joinGuideSections(sections);
179
+ expect(guide.match(/^## Pages, our way$/gm)).toHaveLength(1);
180
+ expect(guide).not.toContain('## HTML views');
181
+ });
182
+
183
+ it('answers a read of AGENTS.md with the start section and every section by id, not the whole guide', async () => {
184
+ const sections = await agentGuideSections(DEFAULT_KB_LAYOUT, undefined, { defaultBranch: 'main' });
185
+ const text = guideStartText(sections);
186
+ const start = sections.find((s) => s.id === 'start-here')!;
187
+ expect(text.startsWith(start.body)).toBe(true);
188
+ for (const s of sections) expect(text).toContain(`- \`${s.id}\`: ${s.title}`);
189
+ expect(text).not.toContain(sections.find((s) => s.id === 'access-control')!.body);
190
+ // A guide a distribution left without a start section is answered whole.
191
+ const without = sections.filter((s) => s.id !== 'start-here');
192
+ expect(guideStartText(without)).toBe(joinGuideSections(without));
193
+ });
194
+
141
195
  it('refuses a hook that drops or empties the shared file rules, which every file tool points at', async () => {
142
196
  await expect(
143
197
  composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) => sections.filter((s) => s.id !== WORKING_WITH_FILES_SECTION_ID)),
@@ -250,8 +304,8 @@ describe('what the guide tells an agent', () => {
250
304
  expect(placement).toContain('Extensions/personal-<id>/');
251
305
  // A skill's own bundled files are part of the skill and stay welcome, so the
252
306
  // rule does not deter an agent from writing a COMPLETE skill.
253
- expect(placement).toContain("each skill's own bundled files");
254
- expect(placement).toContain("inside that skill's folder included");
307
+ expect(placement).toContain("each skill's own files");
308
+ expect(placement).toContain("in that skill's folder");
255
309
  expect(placement).toContain('never a note or any other document');
256
310
  // A private request gets a question and the restrictable folder, in the
257
311
  // deployment's own root name.
@@ -259,8 +313,8 @@ describe('what the guide tells an agent', () => {
259
313
  expect(placement).toContain('restricted so only they can read it');
260
314
  // And when the user insists, the agent declines, says why, and offers again.
261
315
  expect(placement).toContain('If they insist on the personal plugin, decline');
262
- expect(placement).toContain('sits outside the knowledge graph, where it is never found as knowledge again');
263
- expect(placement).toContain('offer a place under `Docs/` once more');
316
+ expect(placement).toContain('only they would ever find it, and never where the organisation looks for knowledge');
317
+ expect(placement).toContain('offer a place under `Docs/` again');
264
318
  // Nothing tells the agent to move or flag documents already filed there.
265
319
  expect(placement).not.toMatch(/\bmove (them|it|any)\b/);
266
320
  });
@@ -281,8 +335,8 @@ describe('what the guide tells an agent', () => {
281
335
  // The four places that introduce it: the `my_plugin` bullet, the sentence
282
336
  // on moving a skill, the placement rule, and the `everyone` note.
283
337
  const prose = guide.replace(/\s+/g, ' ');
284
- expect(prose).toContain("`my_plugin` — your user's personal plugin, holding their own skills and tools");
285
- expect(prose).toContain('A skill moves from a personal plugin into a shared plugin by moving its folder.');
338
+ expect(prose).toContain(`\`my_plugin\`: the user's personal plugin, \`${plugins}/personal-<id>/\`, created on first use.`);
339
+ expect(prose).toContain('A skill moves from a personal plugin to a shared one by moving its folder.');
286
340
  expect(prose).toContain("A person's private skill goes in their personal plugin");
287
341
  expect(prose).toContain(
288
342
  `A person's personal plugin (\`${plugins}/personal-<id>/\`) grants its owner access and denies \`everyone\` outright`,
@@ -299,8 +353,8 @@ describe('what the guide tells an agent', () => {
299
353
  expect(prose).toContain('add people to existing roles, and use a GROUP for a task- or team-scoped set of people');
300
354
  });
301
355
 
302
- it('describes delete_branch where it says what an agent does with branches', async () => {
303
- const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '### An agent proposes and syncs; a person merges');
356
+ it('describes delete_branch where it says what happens to a draft', async () => {
357
+ const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '### When a draft goes away');
304
358
  const prose = text.replace(/\s+/g, ' ');
305
359
  expect(prose).toContain('`delete_branch`');
306
360
  expect(prose).toContain('Only its author');