@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
@@ -279,9 +279,10 @@ export function registerWorkflowTools(
279
279
  mount({
280
280
  name: 'commit_change',
281
281
  description:
282
- 'Save the currently-pending file edits as a new change on the current branch. Each change ' +
283
- 'must affect exactly one file — if multiple files are dirty, returns a structured error ' +
284
- 'listing them; commit them one at a time. Refused on protected branches without write permission.',
282
+ 'Commit, as you, the one file left uncommitted on `branch`, without sharing it. Writes commit and share ' +
283
+ 'on their own moments later (`branch_status` lists those still landing); `unzip` leaves its files uncommitted. ' +
284
+ 'If several files are uncommitted, returns a structured error listing them; commit them one at a time. ' +
285
+ 'Refused on protected branches without write permission.',
285
286
  inputs: {
286
287
  type: 'object',
287
288
  properties: {
@@ -309,7 +310,7 @@ export function registerWorkflowTools(
309
310
  name: 'save_file',
310
311
  description:
311
312
  'Schedule the file at <path> for commit + push using whatever bytes are on disk (no content ' +
312
- 'rewrite) — for binary uploads or files left uncommitted. On lock contention returns ' +
313
+ 'rewrite) — for files left uncommitted, such as those `unzip` extracts. On lock contention returns ' +
313
314
  '`{ saved: false, reason }` rather than throwing. The commit lands asynchronously.',
314
315
  inputs: {
315
316
  type: 'object',
@@ -357,8 +358,9 @@ export function registerWorkflowTools(
357
358
  mount({
358
359
  name: 'share_current_branch',
359
360
  description:
360
- 'Publish the current branch to the shared remote so teammates and any change request pick up ' +
361
- 'your commits. No arguments. Refused on protected branches without write permission.',
361
+ 'Publish `branch` to the shared remote so teammates and any change request pick up its commits. ' +
362
+ 'Writes already share on their own: this is for sharing again after a refused push (`branch_status` shows ' +
363
+ 'the branch ahead). Refused on protected branches without write permission.',
362
364
  inputs: { type: 'object', properties: {}, additionalProperties: false },
363
365
  outputs: {
364
366
  type: 'object',
@@ -377,10 +379,11 @@ export function registerWorkflowTools(
377
379
  description:
378
380
  'Create a new unprotected draft named `name`, forked from the existing branch `branch`. The ' +
379
381
  `backend rejects protected names (${protectedInline(kb)}) and filesystem-invalid names — surface ` +
380
- 'those errors verbatim. Convention: `<email-localpart>/<kebab-slug>`. Does NOT switch the ' +
381
- 'agent onto the new draft.',
382
+ 'those errors verbatim. Name it `<you>/<kebab-slug>`: `<you>` is the person\'s email before the @, in lower case, ' +
383
+ 'with each run of characters other than letters and digits as one `-` (`paul.brandt@` gives `paul-brandt/`). ' +
384
+ 'Only that prefix makes the draft theirs, to delete later. Does NOT switch the agent onto the new draft.',
382
385
  // `branch` here is the fork BASE — its own meaning — so skip the generic
383
- // workspace-`branch` injection ("the branch you are currently working on"),
386
+ // workspace-`branch` injection ("the branch to work on: ... or your draft"),
384
387
  // whose wording invites filling in the draft being created instead.
385
388
  skipBranch: true,
386
389
  // The handler refuses a missing `name` with a message that says what the
@@ -389,7 +392,7 @@ export function registerWorkflowTools(
389
392
  inputs: {
390
393
  type: 'object',
391
394
  properties: {
392
- name: { type: 'string', minLength: 1, description: 'Name of the draft to create (`<email-localpart>/<kebab-slug>` for user drafts). Must not exist yet.' },
395
+ name: { type: 'string', minLength: 1, description: 'Name of the draft to create (`<you>/<kebab-slug>` for user drafts, as the description says). Must not exist yet.' },
393
396
  branch: { type: 'string', minLength: 1, description: 'Existing branch to fork the new draft from — usually the branch you are currently on.' },
394
397
  },
395
398
  required: ['name', 'branch'],
@@ -437,7 +440,7 @@ export function registerWorkflowTools(
437
440
  description:
438
441
  'Open a change request from a draft branch into a target branch. Auto-merges the latest target ' +
439
442
  'into the source first, pushes, then creates the CR. On conflicts returns a ' +
440
- '`change-request-conflicts` error with affected paths. The author marker is injected server-side. ' +
443
+ '`change-request-conflicts` error with affected paths. ' +
441
444
  'Answers a SUMMARY, not the request\'s content: `url` first (hand it to the user), then `number`, ' +
442
445
  '`title`, `state`, `sourceBranch`, `targetBranch`, `approvals` (who must approve each file), ' +
443
446
  '`mergeBlockedReasons` (what blocks the merge), and `files` — the changed paths with their kind of ' +
@@ -549,12 +552,13 @@ export function registerWorkflowTools(
549
552
  name: 'merge_branch',
550
553
  description:
551
554
  'Merge branch `source` into branch `target` as you, and publish `target`. An agent proposes and syncs; ' +
552
- 'a person merges: this tool never lands a change request. It refuses while a change request from `source` ' +
555
+ 'a person merges: this never lands a change request. It refuses while a change request from `source` ' +
553
556
  'into `target` is open (naming it): ask the user to review it in the app. It refuses ' +
554
- 'when `target` is a protected branch unless you could commit every changed file directly to it. ' +
557
+ 'when `target` is a protected branch unless you could commit every changed file directly to it (never ' +
558
+ 'for a change to `roles.yaml`). ' +
555
559
  'SYNC: merging the target into your draft (`source` = the change request\'s target, `target` = your draft) ' +
556
560
  'is always allowed, even with the draft\'s request open. ' +
557
- 'Writes are committed asynchronously; the merge first waits up to 20s for those on `source`. ' +
561
+ 'Writes are committed asynchronously; it waits up to 20s for those on `source`. ' +
558
562
  'Returns `merged` with the merge commit; `nothing-to-merge` with `target`\'s tip when it already holds ' +
559
563
  'all of `source`; `pending-commits` (nothing merged) when writes are still committing — retry shortly — ' +
560
564
  'or one failed (`needsAttention`: tell the user); or `conflicts-need-resolution` with the conflicting ' +
@@ -618,7 +622,7 @@ export function registerWorkflowTools(
618
622
  name: 'delete_branch',
619
623
  description:
620
624
  'Delete branch `name` from the shared repository and the server. Allowed for the ' +
621
- 'branch\'s author (`<email-localpart>/…`, or your own `suggestions/…` bundle) or an Admin. Refuses a ' +
625
+ 'branch\'s author (`<you>/…` per `create_branch`, or your own `suggestions/…` bundle) or an Admin. Refuses a ' +
622
626
  'protected branch, a name that is not a branch, a branch with saves still landing or a file held ' +
623
627
  '(retry once they land). An open change request from or into it that proposes nothing is closed and the ' +
624
628
  'delete goes ahead; one that still proposes something refuses it, naming and linking it: its author ' +
@@ -836,13 +840,13 @@ export function registerWorkflowTools(
836
840
  'One file as it was at a point in history: `at` is a commit (from `list_file_changes`, on `branch`\'s ' +
837
841
  'own history) or the name of another branch (its published tip). Answers `content` (null when the ' +
838
842
  'file was not there) and `sha`, the commit it was read at; for a commit, also `before`, the file ' +
839
- 'just before that commit. To put an earlier version back, write it with `write_file`. Takes the read ' +
840
- 'the file takes at that point.',
843
+ 'just before that commit. To put an earlier version back, write it with `write_file`. Takes your ' +
844
+ 'read of the file on `branch` now and, for a branch, also at its tip.',
841
845
  inputs: {
842
846
  type: 'object',
843
847
  properties: {
844
848
  path: { type: 'string', minLength: 1, description: 'The file, workspace-relative.' },
845
- at: { type: 'string', minLength: 1, description: 'A commit sha, or a branch name.' },
849
+ at: { type: 'string', minLength: 1, description: 'A full commit sha (40 or 64 hex characters), or a branch name.' },
846
850
  sessionId: SESSION_ID_INPUT,
847
851
  },
848
852
  required: ['path', 'at'],
@@ -4,19 +4,17 @@ import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
6
6
  import { testKbContext } from '../../../__tests__/kb-context.js';
7
- import { ToolRegistry } from '../../tool-registry/tool-registry.js';
8
- import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
9
- import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
10
- import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
11
- import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import { GUIDE_FIRST_SENTENCE, ToolRegistry } from '../../tool-registry/index.js';
8
+ import { WorkflowHooks, createToolHandlerFactory } from '../../../__tests__/tool-wiring.js';
9
+ import type { ToolAuth } from '../../tool-auth/index.js';
10
+ import type { IAccessControl } from '../../access/index.js';
12
11
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
13
12
  import { WorkerDocumentExtractor } from '../file-readers/extract-in-worker.js';
14
13
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
15
14
  import { SpillStore } from '../spill-store.js';
16
- import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
17
15
  import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
18
16
  import { registerWorkspaceTools } from '../workspace.tools.js';
19
- import { sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
17
+ import { sharedFileRulesSection } from '../../agent-instructions/index.js';
20
18
  import { RipgrepPatternMatcher } from '../index.js';
21
19
 
22
20
  /** The grep matcher this file's harness wires, closed once the file is done. */
@@ -80,10 +78,10 @@ async function listed(registry: ToolRegistry): Promise<Map<string, string>> {
80
78
  afterEach(() => kb.applyLayout({ ...DEFAULT_KB_LAYOUT }));
81
79
 
82
80
  describe('what tells an agent to read the guide first', () => {
83
- it('tells an agent to call get_agent_guide first, and that AGENTS.md answers with the same guide after the organisation\'s own', () => {
81
+ it('tells an agent to call get_agent_guide first, and that AGENTS.md answers with that start after the organisation\'s own', () => {
84
82
  const rules = sharedFileRulesSection(kb.layout);
85
- expect(rules).toContain("call `get_agent_guide` and read the platform's guide");
86
- expect(rules).toContain('read_file on `AGENTS.md` at the KB root answers with the same guide');
83
+ expect(rules).toContain("call `get_agent_guide`: it answers the guide's start");
84
+ expect(rules).toContain('read_file on `AGENTS.md` at the KB root answers with that start');
87
85
  expect(rules).toContain("after the organisation's own conventions file of that name");
88
86
  expect(rules).toContain('`CLAUDE.md`');
89
87
  // One name on every deployment: the rule names no other.
@@ -10,40 +10,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
11
11
  import { slowRipgrep } from '../../../__tests__/slow-ripgrep.js';
12
12
  import { LocalFilesystem } from '@mastra/core/workspace';
13
- import { ToolRegistry } from '../../tool-registry/tool-registry.js';
14
- import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
15
- import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
16
- import type { ToolAuth } from '../../tool-auth/tool-auth.middleware.js';
13
+ import { GUIDE_FIRST_SENTENCE, ToolRegistry } from '../../tool-registry/index.js';
14
+ import { AccessControlService, WorkflowHooks, createToolHandlerFactory, type AgentOperationContext } from '../../../__tests__/tool-wiring.js';
15
+ import { ToolError, type ToolContext } from '../../tool-helpers/index.js';
16
+ import type { ToolAuth } from '../../tool-auth/index.js';
17
17
  import { registerWorkspaceTools } from '../workspace.tools.js';
18
18
  import { RipgrepPatternMatcher, type RipgrepPatternMatcherOptions } from '../index.js';
19
- import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/shared-file-rules.js';
20
- import { composeAgentGuide } from '../../agent-guide/agent-guide.js';
21
- import { GUIDE_FIRST_SENTENCE } from '../../tool-registry/guide-first.js';
19
+ import { sharedFileRules, sharedFileRulesSection } from '../../agent-instructions/index.js';
20
+ import { composeAgentGuide } from '../../agent-guide/index.js';
22
21
  import { RoutineWritePolicyService } from '../routine-write-policy.js';
23
22
  import { UuidSessionSink, type ISessionSink } from '../session-sink.js';
24
23
  import { FIRST_RUN_SECTION_ID, STARTER_GUIDE_FILE, firstRunNote, type FirstRunStarterSource } from '../first-run.js';
25
- import { WorkflowHooks, type AgentOperationContext } from '../../workflow/workflow-hooks.js';
26
24
  import { SESSION_ID_DESCRIPTION, ToolDescriptionNotes } from '../agent-access.gate.js';
27
25
  import { SpillStore } from '../spill-store.js';
28
26
  import { DocExtractService } from '../file-readers/doc-extract.service.js';
29
27
  import { WorkerDocumentExtractor } from '../file-readers/extract-in-worker.js';
30
28
  import { OCTET_STREAM_FALLBACK_NOTE } from '../file-readers/content-mode.js';
31
- import type { IAccessControl } from '../../access/access-control.interface.js';
32
- import { AccessControlService } from '../../access/access-control.service.js';
33
- import { NodeFs } from '../../kb-fs/node-fs.js';
29
+ import type { IAccessControl } from '../../access/index.js';
30
+ import { realDisk } from '../../../__tests__/real-disk.js';
34
31
  import type { WorkspaceService } from '../workspace.service.js';
35
32
  import { DEFAULT_KB_LAYOUT, isBranchAuthoredBy, isOwnSuggestionsBranch } from '@bevel-software/platform-shared';
36
- import { normalizeWorkspacePath } from '../../kb-fs/repo-path.js';
33
+ import { normalizeWorkspacePath } from '../../kb-fs/index.js';
37
34
  import { GIT_INTERNALS_MESSAGE, PathNotFoundError } from '../../../shared/domain-errors.js';
38
- import { AccessDeniedError } from '../../access-model/access-errors.js';
35
+ import { AccessDeniedError } from '../../access-model/index.js';
39
36
  import { proposalTitleFor } from '../write-denial.js';
40
37
  import { NOT_FOUND_NEXT_STEP } from '../not-found.js';
41
38
  import { compileCheck, exampleArguments } from '@bevel-software/platform-mcp-core';
42
- import { routeToolSchemas } from '../../tool-helpers/route-tool-schemas.js';
43
- import { TOOL_DESCRIPTION_CAP, clientVisibleLength } from '../../tool-registry/description-length.js';
44
39
 
45
40
  const KB_DIR = 'knowledge-base';
46
41
 
42
+ /** A move that changes nobody's access: what a stand-in without rules answers. */
43
+ const NO_CHANGES = { lose: [], gain: [] };
44
+
47
45
  /** Allow-all access control — the default for tests not exercising read gating. */
48
46
  const allowAll = {
49
47
  canRead: async () => true,
@@ -67,6 +65,8 @@ const allowAll = {
67
65
  // a folder's `access.md` files actually does to it is exercised against
68
66
  // REAL rules in "a move preview judges the destination as it will be".
69
67
  previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
68
+ // Who else loses or gains access in a move: nobody, here.
69
+ prospectiveHolderChanges: async () => NO_CHANGES,
70
70
  } as unknown as IAccessControl;
71
71
 
72
72
  /** Access control that denies `canRead` for an explicit set of repo-relative paths. */
@@ -2124,30 +2124,12 @@ describe('office documents and PDFs', () => {
2124
2124
  // Narrowed by hand: the assertion above already failed the test if not.
2125
2125
  if (!compiled.checkable) throw new Error(compiled.reason);
2126
2126
  expect(compiled.check(exampleArguments(def.inputs)), def.name).toEqual([]);
2127
- // And the FLAT schema, which is what the tool's own route checks the
2128
- // call against: the two must agree, or a call the example produced would
2129
- // be refused one layer in.
2130
- const flat = routeToolSchemas(def.name)?.flat;
2131
- expect(flat, def.name).toBeDefined();
2132
- const flatCheck = compileCheck(flat);
2133
- expect(flatCheck.checkable, `${def.name} (flat): ${flatCheck.checkable ? '' : flatCheck.reason}`).toBe(true);
2134
- if (!flatCheck.checkable) throw new Error(flatCheck.reason);
2135
- expect(flatCheck.check(exampleArguments(flat)), `${def.name} (flat)`).toEqual([]);
2136
- }
2137
- });
2138
-
2139
- it('every description, call example and purpose prefix included, stays inside the cap a client shows', async () => {
2140
- await start();
2141
- const tools = await toolRegistry.listInternal();
2142
- for (const def of tools) {
2143
- // Measured as a CLIENT receives it: the call line, the purpose prefix at
2144
- // its own cap (the four knowledge-base tools carry one), and the
2145
- // description — the three things that ride one tool's entry.
2146
- const received = clientVisibleLength(def);
2147
- expect(received, `${def.name} is ${received} characters (cap ${TOOL_DESCRIPTION_CAP})`).toBeLessThanOrEqual(
2148
- TOOL_DESCRIPTION_CAP,
2149
- );
2150
2127
  }
2128
+ // That the FLAT schema each route checks a call against agrees with the
2129
+ // example is the route layer's own property: tool-helpers'
2130
+ // `route-tool-schemas.catalog.test.ts` holds it over the whole catalog.
2131
+ // And that every description stays inside the cap a client shows is the
2132
+ // registry's: `tool-description-length.test.ts`, over the same catalog.
2151
2133
  });
2152
2134
 
2153
2135
  describe('binary capability contract: a text file, a document, an image and a zip', () => {
@@ -2475,7 +2457,7 @@ describe('start_session', () => {
2475
2457
  undefined,
2476
2458
  undefined /* downloads */,
2477
2459
  starterPacks,
2478
- new NodeFs(),
2460
+ realDisk(),
2479
2461
  );
2480
2462
  app.use('/api', router);
2481
2463
  server = await new Promise<HttpServer>((r) => {
@@ -3256,7 +3238,8 @@ describe('preflight for moves and deletes', () => {
3256
3238
  if (rel === 'sealed.md' || rel.endsWith('/sealed.md')) return { read: true, write: false, download: false, owner: false };
3257
3239
  if (rel.startsWith('HR/')) return { read: true, write: true, download: false, owner: false };
3258
3240
  if (rel.startsWith('Locked/')) return { read: true, write: false, download: false, owner: false };
3259
- if (rel.startsWith('Secret/')) return { read: false, write: false, download: false, owner: false };
3241
+ // The folder itself answers as its contents do, as the resolver's scopes do.
3242
+ if (rel === 'Secret' || rel.startsWith('Secret/')) return { read: false, write: false, download: false, owner: false };
3260
3243
  return { read: true, write: true, download: true, owner: true };
3261
3244
  };
3262
3245
  const rules = {
@@ -3273,6 +3256,7 @@ describe('preflight for moves and deletes', () => {
3273
3256
  // preview learned to relocate them, which keeps every ordering and
3274
3257
  // oracle test below about what it is about.
3275
3258
  previewAccessAfterRelocation: async (_w: string, _u: string, _from: string, to: string) => verbsFor(to),
3259
+ prospectiveHolderChanges: async () => NO_CHANGES,
3276
3260
  } as unknown as IAccessControl;
3277
3261
 
3278
3262
  const call = async (base: string, tool: string, body: Record<string, unknown>) => {
@@ -3401,10 +3385,12 @@ describe('preflight for moves and deletes', () => {
3401
3385
  writes.push(op.wsPath ?? '');
3402
3386
  });
3403
3387
  expect((await call(base, 'move_file', args)).body).toMatchObject({ moved: true });
3404
- // `Locked/refs.md` is named (left for want of write access, its links
3405
- // listed), so the read hook hears of it; the unreadable page and the
3406
- // transcript are never named, so it never hears of them.
3407
- expect(reads.sort()).toEqual([KB('Locked/refs.md'), KB('Sales/deal.md'), KB('Sales/index.md')]);
3388
+ // The move's two ends first, as read_file tells it (what moves, and
3389
+ // the folder it lands in); then `Locked/refs.md` is named (left for
3390
+ // want of write access, its links listed), so the read hook hears of
3391
+ // it; the unreadable page and the transcript are never named, so it
3392
+ // never hears of them.
3393
+ expect(reads.sort()).toEqual([KB('Locked/refs.md'), KB('Sales/2026'), KB('Sales/deal.md'), KB('Sales/deal.md'), KB('Sales/index.md')]);
3408
3394
  expect([...new Set(writes)].sort()).toEqual([KB('Sales/2026/deal.md'), KB('Sales/deal.md'), KB('Sales/index.md')]);
3409
3395
  });
3410
3396
 
@@ -3450,6 +3436,32 @@ describe('preflight for moves and deletes', () => {
3450
3436
  expect(await exists(args.src)).toBe(false);
3451
3437
  });
3452
3438
 
3439
+ it('a caller who cannot read what moves is refused as read_file refuses them, on a draft too, and nothing is looked at', async () => {
3440
+ const resolved: string[] = [];
3441
+ const base = await start('write', {
3442
+ ...rules,
3443
+ prospectiveHolderChanges: async (_w: string, from: string) => {
3444
+ resolved.push(from);
3445
+ return { lose: [{ kind: 'group', name: 'Board', verb: 'read' }], gain: [] };
3446
+ },
3447
+ } as unknown as IAccessControl);
3448
+ await fs.writeFile(KB('Secret/plan.md'), 'plan');
3449
+ // A draft is not write-gated, so only the read gate stands between a
3450
+ // caller and a move of a file they may not open — and it does: the dry
3451
+ // run, the unconfirmed move and the confirmed one are all read_file's
3452
+ // own refusal, with nobody resolved and nothing moved.
3453
+ const args = { branch: 'someone/draft', src: KB('Secret/plan.md'), dest: KB('Secret/old/plan.md') };
3454
+ const read = await call(base, 'read_file', { branch: args.branch, path: args.src });
3455
+ expect(read.status).toBe(403);
3456
+ for (const body of [{ ...args, dryRun: true }, args, { ...args, confirm: true }]) {
3457
+ const res = await call(base, 'move_file', body);
3458
+ expect(res.status).toBe(403);
3459
+ expect(res.body).toEqual(read.body);
3460
+ }
3461
+ expect(resolved).toEqual([]);
3462
+ expect(await exists(args.src)).toBe(true);
3463
+ });
3464
+
3453
3465
  it('an access-changing move shows before/after and runs only with confirm: true', async () => {
3454
3466
  const base = await seeded();
3455
3467
  const args = { src: KB('Sales/deal.md'), dest: KB('HR/deal.md') };
@@ -3648,13 +3660,18 @@ describe('preflight for moves and deletes', () => {
3648
3660
  expect(await exists(KB('Sales/moved'))).toBe(false);
3649
3661
  });
3650
3662
 
3651
- it('a denied move into a path the caller cannot read cannot be proposed, and says why', async () => {
3663
+ it('a move into a folder the caller cannot read is refused as read_file refuses the folder, before the write verdict', async () => {
3652
3664
  const base = await seeded();
3665
+ // The folder's rules are what a move preview would read back (the
3666
+ // caller's verdicts there, who else is affected), so the read refusal
3667
+ // comes first — not the write denial, which would say whether the path
3668
+ // can be proposed and name the folder's contents on a collision.
3653
3669
  const run = await call(base, 'move_file', { src: KB('Sales/deal.md'), dest: KB('Secret/deal.md'), confirm: true });
3654
3670
  expect(run.status).toBe(403);
3655
- expect(run.body).toMatchObject({ kind: 'write-denied', canPropose: false });
3671
+ expect(run.body.error).toContain(`permission to read "${KB('Secret')}"`);
3672
+ expect(run.body.kind).toBeUndefined();
3656
3673
  expect(run.body.proposal).toBeUndefined();
3657
- expect(run.body.cannotProposeReason).toContain('you cannot read this path');
3674
+ expect(await exists(KB('Sales/deal.md'))).toBe(true);
3658
3675
  });
3659
3676
 
3660
3677
  it('a draft branch is not write-gated, so the same move is allowed there', async () => {
@@ -4225,7 +4242,8 @@ describe('preflight for moves and deletes', () => {
4225
4242
  // The protocol itself, and what a platform folder does to it, are shared.
4226
4243
  const rules = sharedFileRulesSection(testKbContext().layout);
4227
4244
  expect(rules).toContain('`dryRun: true`');
4228
- expect(rules).toContain('A non-empty folder is deleted, and a move that changes your access runs, only with `confirm: true`');
4245
+ // Anyone's: a move asks when it changes who else may open or edit the file too (`holderChanges`).
4246
+ expect(rules).toContain("A non-empty folder is deleted, and a move that changes anyone's access runs, only with `confirm: true`");
4229
4247
  expect(rules).toContain('platform folder');
4230
4248
  const dry = await call(base, 'delete_folder', { path: KB('Sales/archive'), dryRun: true });
4231
4249
  const unconfirmed = await call(base, 'delete_folder', { path: KB('Sales/archive') });
@@ -4291,6 +4309,7 @@ describe('a write refused for permissions says whether and how to propose it', (
4291
4309
  canOwner: async () => true,
4292
4310
  canWriteBatchAtRef: async () => null,
4293
4311
  previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
4312
+ prospectiveHolderChanges: async () => NO_CHANGES,
4294
4313
  eligibleWritersAtRef: async () => ({ roles: ['Sales Lead'], users: [{ name: 'Owner', email: 'owner@x' }] }),
4295
4314
  } as unknown as IAccessControl;
4296
4315
  return { ac, calls };
@@ -5134,7 +5153,7 @@ describe('a move preview judges the destination as it will be', () => {
5134
5153
  ensureRemotesFetched: async () => undefined,
5135
5154
  } as unknown as WorkspaceService,
5136
5155
  KB_DIR,
5137
- new NodeFs(),
5156
+ realDisk(),
5138
5157
  );
5139
5158
  return {
5140
5159
  canRead: (w: string, u: string, r: string) => fresh().canRead(w, u, r),
@@ -5146,6 +5165,7 @@ describe('a move preview judges the destination as it will be', () => {
5146
5165
  previewAccessAfterRelocation: (
5147
5166
  w: string, u: string, from: string, to: string, opts?: { sourceRemains?: boolean },
5148
5167
  ) => fresh().previewAccessAfterRelocation(w, u, from, to, opts),
5168
+ prospectiveHolderChanges: (w: string, from: string, to: string) => fresh().prospectiveHolderChanges(w, from, to),
5149
5169
  } as unknown as IAccessControl;
5150
5170
  };
5151
5171
 
@@ -5189,6 +5209,8 @@ describe('a move preview judges the destination as it will be', () => {
5189
5209
  expect(dry.body).toMatchObject({ kind: 'folder', accessChanges: false, allowed: true, moved: false });
5190
5210
  expect(dry.body.access.after).toEqual(dry.body.access.before);
5191
5211
  expect(dry.body.access.after.owner).toBe(true);
5212
+ // A rename carries its own rules along: nobody else gains or loses either.
5213
+ expect(dry.body.holderChanges).toBeUndefined();
5192
5214
  // Nothing moved and nothing was written to answer the question.
5193
5215
  expect(await fs.exists(args.dest)).toBe(false);
5194
5216
  expect(await fs.exists(KB('Sales/access.md'))).toBe(true);
@@ -5213,6 +5235,91 @@ describe('a move preview judges the destination as it will be', () => {
5213
5235
  expect(await verbsAt(base, args.dest)).toEqual(dry.body.access.after);
5214
5236
  });
5215
5237
 
5238
+ it('a move that changes only who ELSE may edit it names them, and asks before it moves', async () => {
5239
+ const base = await seeded();
5240
+ // `Ops/` gives the caller what `Sales/` gives, and Ops Lead write as well.
5241
+ await fs.writeFile(
5242
+ KB('Ops/access.md'),
5243
+ rules(`write:\n - Mover <${MOVER}>\n - Ops Lead <ops@x.io>\nowner:\n - Mover <${MOVER}>\n`),
5244
+ );
5245
+ const args = { src: KB('Sales/deal.md'), dest: KB('Ops/deal.md') };
5246
+
5247
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
5248
+ expect(dry.body).toMatchObject({ accessChanges: false, allowed: true });
5249
+ // Everyone may read on both sides, so the only change is who may edit.
5250
+ expect(dry.body.holderChanges).toEqual({
5251
+ lose: [],
5252
+ gain: [{ kind: 'person', name: 'ops@x.io', email: 'ops@x.io', verb: 'write' }],
5253
+ });
5254
+
5255
+ const unconfirmed = await call(base, 'move_file', args);
5256
+ expect(unconfirmed.body).toMatchObject({ confirmationRequired: true, moved: false });
5257
+ expect(unconfirmed.body.message).toContain('`holderChanges`');
5258
+ expect(await fs.exists(args.src)).toBe(true);
5259
+ expect((await call(base, 'move_file', { ...args, confirm: true })).body).toMatchObject({ moved: true });
5260
+ });
5261
+
5262
+ it('a source the read hook refuses is refused as read_file refuses it: no preview, not even that the move would ask', async () => {
5263
+ const base = await seeded();
5264
+ await fs.writeFile(
5265
+ KB('Ops/access.md'),
5266
+ rules(`write:\n - Mover <${MOVER}>\n - Ops Lead <ops@x.io>\nowner:\n - Mover <${MOVER}>\n`),
5267
+ );
5268
+ const args = { src: KB('Sales/deal.md'), dest: KB('Ops/deal.md'), rewriteLinks: false };
5269
+ // The rules let the caller read it; a deployment's read hook does not. The
5270
+ // hook's refusal is the whole answer: a "this move asks for confirm" would
5271
+ // already say what the comparison found behind the hook.
5272
+ hooks.onAgentRead(async (op) => {
5273
+ if (op.wsPath === args.src) throw new Error('not for agents');
5274
+ });
5275
+ const read = await call(base, 'read_file', { path: args.src });
5276
+ expect(read.status).toBeGreaterThanOrEqual(400);
5277
+ for (const body of [{ ...args, dryRun: true }, args, { ...args, confirm: true }]) {
5278
+ const res = await call(base, 'move_file', body);
5279
+ expect(res.status).toBe(read.status);
5280
+ expect(res.body).toEqual(read.body);
5281
+ expect(JSON.stringify(res.body)).not.toContain('ops@x.io');
5282
+ }
5283
+ expect(await fs.exists(args.src)).toBe(true);
5284
+ });
5285
+
5286
+ it('a caller who reads what moves but not the folder it lands in is refused, on a draft too, and told nothing of the folder', async () => {
5287
+ const base = await seeded();
5288
+ // `Closed/` is somebody else's: its rules are what a move preview would
5289
+ // read back (who it lets in, whom it denies). On a draft no write gate
5290
+ // stands, so the read gate is what refuses — before anything is looked at.
5291
+ await fs.writeFile(
5292
+ KB('Closed/access.md'),
5293
+ rules('read:\n - Other <other@x.io>\n - deny everyone\nwrite:\n - Other <other@x.io>\n'),
5294
+ );
5295
+ await fs.writeFile(KB('Closed/deal.md'), 'taken');
5296
+ const args = { branch: 'someone/draft', src: KB('Sales/deal.md'), dest: KB('Closed/deal.md'), rewriteLinks: false };
5297
+
5298
+ for (const body of [{ ...args, dryRun: true }, args, { ...args, confirm: true }]) {
5299
+ const res = await call(base, 'move_file', body);
5300
+ expect(res.status).toBe(403);
5301
+ expect(res.body.error).toContain(`permission to read "${KB('Closed')}"`);
5302
+ // Neither the folder's rules nor what sits in it: the name clash at
5303
+ // `Closed/deal.md` is never reached.
5304
+ const text = JSON.stringify(res.body);
5305
+ expect(text).not.toContain('other@x.io');
5306
+ expect(text).not.toContain('already exists');
5307
+ }
5308
+ expect(await fs.exists(args.src)).toBe(true);
5309
+ expect(await fs.readFile(KB('Closed/deal.md'), { encoding: 'utf8' })).toBe('taken');
5310
+ });
5311
+
5312
+ it('a move out of a folder names everyone it leaves behind', async () => {
5313
+ const base = await seeded();
5314
+ const args = { src: KB('Work/Notes'), dest: KB('Legal/Notes') };
5315
+ const dry = await call(base, 'move_file', { ...args, dryRun: true });
5316
+ // Everyone may still read it in Legal/: the caller loses editing, not opening.
5317
+ expect(dry.body.holderChanges).toEqual({
5318
+ lose: [{ kind: 'person', name: MOVER, email: MOVER, verb: 'write' }],
5319
+ gain: [],
5320
+ });
5321
+ });
5322
+
5216
5323
  it('what the folder inherited is left behind; what its own access.md gives comes along', async () => {
5217
5324
  const base = await seeded();
5218
5325
  const args = { src: KB('Work/Team'), dest: KB('Legal/Team') };
@@ -32,7 +32,7 @@ import type { WorkflowHooks } from '../workflow/index.js';
32
32
  * takes, so a run's reads and its questions share one conversation.
33
33
  */
34
34
  export const SESSION_ID_DESCRIPTION =
35
- "This conversation's id, so the server can tell one run's calls apart. External agents (direct MCP calls and `call_tool_chain` alike): call the `start_session` tool ONCE at the very start of your work to obtain it, then pass that id here — the same id `ask` takes. The in-process agent runtime supplies this automatically (do not set it yourself).";
35
+ "This conversation's id: call `start_session` once, at the start of your work, and pass its id here, inside `call_tool_chain` too (and to `ask`, where the deployment offers it). The in-process agent runtime sets it itself.";
36
36
 
37
37
  /**
38
38
  * The `sessionId` input every gated tool declares. The description a MOUNTED