@bevel-software/platform-shared 0.4.1 → 0.5.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.
package/src/git/types.ts CHANGED
@@ -113,7 +113,20 @@ export interface IGitService {
113
113
  user: AuthUser,
114
114
  req: ShareChangesRequest,
115
115
  ): Promise<CommitAttribution>;
116
- push(workspaceId: string, user: AuthUser): Promise<void>;
116
+ push(
117
+ workspaceId: string,
118
+ user: AuthUser,
119
+ opts?: {
120
+ /**
121
+ * Skip the per-user protected-branch access gate for THIS push. Only
122
+ * for system-authorized flows whose endpoint is itself the
123
+ * authorization (group provisioning: any signed-in user may claim an
124
+ * unused name under `Groups/`, and the seeded access.md governs
125
+ * everything after). Never thread a raw user request into this.
126
+ */
127
+ systemAuthorized?: boolean;
128
+ },
129
+ ): Promise<void>;
117
130
  fetch(workspaceId: string): Promise<void>;
118
131
  pull(workspaceId: string): Promise<void>;
119
132
  diffStat(workspaceId: string, base?: string): Promise<string[]>;
@@ -130,11 +143,6 @@ export interface IGitService {
130
143
  * count. Returns null if no protected branch can be reached.
131
144
  */
132
145
  resolveForkBase(workspaceId: string, branch: string): Promise<string | null>;
133
- revertCommit(
134
- workspaceId: string,
135
- user: AuthUser,
136
- sha: string,
137
- ): Promise<CommitAttribution>;
138
146
  logForFile(
139
147
  workspaceId: string,
140
148
  relativePath: string,
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from './chat/types.js';
8
8
  export * from './workspace/types.js';
9
9
  export * from './workspace/filename.js';
10
10
  export * from './workspace/kb-layout.js';
11
+ export * from './workspace/join-request.js';
11
12
  export * from './workspace/frontmatter.js';
12
13
 
13
14
  // Git
@@ -151,12 +151,6 @@ export interface IWorkflowService {
151
151
  ): Promise<Change | null>;
152
152
  /** Per-file history. Newest first; clamps to ≤ 100 entries. */
153
153
  listChangesForFile(workspaceId: string, path: string, limit?: number): Promise<Change[]>;
154
- /**
155
- * Revert a single change by creating a new change that undoes it.
156
- * Refused on protected branches; refused without write permission on the
157
- * touched paths.
158
- */
159
- revertChange(workspaceId: string, user: AuthUser, sha: string): Promise<Change>;
160
154
  /**
161
155
  * Diff of one file between two branches. Both names must resolve to a
162
156
  * known branch on this workspace (local head or remote-tracking). Returns
@@ -175,6 +169,17 @@ export interface IWorkflowService {
175
169
  * Used by the File viewer's History tab.
176
170
  */
177
171
  showFileAtChange(workspaceId: string, path: string, sha: string): Promise<string>;
172
+ /**
173
+ * Full file contents on both sides of one change: `baseline` at `<sha>^`
174
+ * (null when the file — or the parent commit — doesn't exist there),
175
+ * `current` at `<sha>` (null when absent). Used by the File viewer's
176
+ * History tab to render markdown changes as a rendered-markdown diff.
177
+ */
178
+ fileAtChange(
179
+ workspaceId: string,
180
+ path: string,
181
+ sha: string,
182
+ ): Promise<{ baseline: string | null; current: string | null }>;
178
183
 
179
184
  // ── File locks (new — currently NotImplementedWorkflowError) ──────────────
180
185
 
@@ -255,8 +260,11 @@ export interface IWorkflowService {
255
260
  // ── Change Requests ───────────────────────────────────────────────────────
256
261
 
257
262
  listChangeRequests(opts?: { fresh?: boolean }): Promise<ChangeRequest[]>;
258
- /** Change requests authored by the caller (hash-matched against the body marker). */
259
- listChangeRequestsAuthoredBy(emailOrLogin: string): Promise<ChangeRequest[]>;
263
+ /** Change requests authored by the given user (matched on stored author identity). */
264
+ listChangeRequestsAuthoredBy(
265
+ emailOrLogin: string,
266
+ opts?: { fresh?: boolean },
267
+ ): Promise<ChangeRequest[]>;
260
268
  /** Change requests touching files the user has write permission on. */
261
269
  listChangeRequestsForUser(
262
270
  workspaceId: string,
@@ -323,6 +331,33 @@ export interface IWorkflowService {
323
331
  workspaceId: string,
324
332
  ): Promise<FileApproval[]>;
325
333
 
334
+ /**
335
+ * Decline ONE file of an open change request: restore its merge-base
336
+ * version on the source branch so it drops out of the request's diff.
337
+ * Same permission as approving the file. When the last file is declined
338
+ * the request closes itself and its source branch is retired
339
+ * (`closed: true` in the result).
340
+ */
341
+ revertChangeRequestFile(
342
+ number: number,
343
+ user: AuthUser,
344
+ repoRelPath: string,
345
+ ): Promise<{ closed: boolean; remainingPaths: string[] }>;
346
+
347
+ /**
348
+ * Close an OPEN change request whose diff has emptied (authoritatively
349
+ * re-verified; a failed diff never closes) and retire its source branch.
350
+ * Returns true when this call closed it.
351
+ */
352
+ closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean>;
353
+
354
+ /**
355
+ * Delete a change request outright: close it and retire its source branch.
356
+ * Admin-only (write on `roles.yaml` at `origin/<base>`); refuses a merged
357
+ * request.
358
+ */
359
+ deleteChangeRequest(number: number, user: AuthUser): Promise<void>;
360
+
326
361
  /**
327
362
  * Reject a change request without merging. Authorized for the author or
328
363
  * for users with write permission to every changed file on the target
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The join-request naming convention — how "asking to join a group" rides on
3
+ * plain change requests with zero new backend state.
4
+ *
5
+ * A join request IS a change request: a draft branch carrying one commit that
6
+ * adds the requester to the group's `access.md` body `read:` list, opened
7
+ * against the default branch. What makes it recognisable — to the `Requested`
8
+ * chip, to the manager-side proposals surface, to idempotency — is only this
9
+ * branch-name convention. Both sides (backend, frontend) read it from here so
10
+ * they can never drift.
11
+ *
12
+ * <email-localpart>/join-<group-kebab>-<groupTag>-<requesterTag>
13
+ *
14
+ * follows the workspace's existing `<email-localpart>/<kebab-slug>` draft
15
+ * convention, so join branches sort with the requester's other drafts and
16
+ * the branch-authorship delete rule applies unchanged.
17
+ *
18
+ * WHY TWO TAGS: the localpart and the kebab slug are both LOSSY, and each
19
+ * loss has its own failure:
20
+ *
21
+ * - `groupTag` — over the EXACT group name, recomputable by anyone who
22
+ * knows the group. `Finance!` and `finance` share the slug `finance`;
23
+ * without this tag, listing one group's requests would match the other's
24
+ * branches, read the wrong `access.md` (unchanged on that branch), see an
25
+ * empty diff and SETTLE the request — closing a change request and
26
+ * deleting a branch that belong to a different group. The settle decision
27
+ * is "the diff is empty", so group identity must be exact BEFORE the diff
28
+ * is consulted, and `isJoinBranchFor` recomputes this tag to make it so.
29
+ * - `requesterTag` — over the full email (+ group), NOT recomputable by a
30
+ * reader (a manager listing requests does not know each requester's
31
+ * email; `isJoinBranchFor` matches its shape only). It exists so
32
+ * `ali@bevel.software` and `ali@other.com` never share a branch — else
33
+ * the second requester's commit lands on the first one's branch.
34
+ *
35
+ * The tags are collision-avoidance devices, not a security boundary —
36
+ * nothing grants access based on a branch name. Grants happen through the
37
+ * ordinary access mutation path, and settling only ever closes a request
38
+ * whose file adds nothing over the default branch.
39
+ */
40
+
41
+ /** Group name → the kebab slug used in the join branch (lossy by design). */
42
+ export function kebabGroupName(group: string): string {
43
+ return group
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/\s+/g, '-')
47
+ .replace(/[^a-z0-9-]/g, '')
48
+ .replace(/-+/g, '-')
49
+ .replace(/^-|-$/g, '');
50
+ }
51
+
52
+ /** Length of each branch-name tag, in base-36 characters. */
53
+ const TAG_LENGTH = 7;
54
+
55
+ /**
56
+ * Short deterministic tag over an exact string (FNV-1a, base36).
57
+ *
58
+ * Synchronous and dependency-free so the same value is derivable in the
59
+ * browser and on the server; `crypto.subtle` is async and would force this
60
+ * whole convention to become promise-shaped for no benefit. Not a hash in the
61
+ * security sense and does not need to be — see the module note.
62
+ */
63
+ function shortTag(input: string): string {
64
+ let hash = 0x811c9dc5;
65
+ for (let i = 0; i < input.length; i++) {
66
+ hash ^= input.charCodeAt(i);
67
+ // FNV prime, via shifts so the whole thing stays in 32-bit int math.
68
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
69
+ }
70
+ return hash.toString(36).padStart(TAG_LENGTH, '0').slice(-TAG_LENGTH);
71
+ }
72
+
73
+ /** The exact-group tag — recomputable from the group name alone. */
74
+ function groupTag(group: string): string {
75
+ return shortTag(`g:${group}`);
76
+ }
77
+
78
+ /** The deterministic join branch for (requester, group). */
79
+ export function joinBranchFor(email: string, group: string): string {
80
+ const normalizedEmail = email.trim().toLowerCase();
81
+ const localpart = normalizedEmail.split('@')[0] || normalizedEmail;
82
+ const slug = kebabGroupName(group);
83
+ const requester = shortTag(`u:${normalizedEmail}\n${group}`);
84
+ // A group whose name has no alphanumerics at all (`!!!`) kebabs to '' — the
85
+ // tags alone still yield a valid, matchable branch.
86
+ const middle = slug ? `${slug}-` : '';
87
+ return `${localpart}/join-${middle}${groupTag(group)}-${requester}`;
88
+ }
89
+
90
+ /**
91
+ * Does `branch` look like SOMEBODY's join branch for EXACTLY `group`?
92
+ *
93
+ * The group half (slug + `groupTag`) is recomputed and must match — this is
94
+ * what keeps slug-colliding groups (`Finance!` vs `finance`) from seeing,
95
+ * and worse settling, each other's requests. The requester half cannot be
96
+ * recomputed (the reader has no email) and matches by shape; the requester's
97
+ * identity comes from the change request's own attribution.
98
+ */
99
+ export function isJoinBranchFor(branch: string, group: string): boolean {
100
+ const slug = kebabGroupName(group);
101
+ const middle = slug ? `${escapeRegExp(slug)}-` : '';
102
+ const requester = `[0-9a-z]{${TAG_LENGTH}}`;
103
+ return new RegExp(`^[^/]+/join-${middle}${groupTag(group)}-${requester}$`).test(branch);
104
+ }
105
+
106
+ function escapeRegExp(s: string): string {
107
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
+ }
@@ -1,3 +1,5 @@
1
+ import { branchSegment } from '../git/branchAuthor.js';
2
+
1
3
  /**
2
4
  * Top-level layout of the KB repo (inside the `KB_DIR_NAME` clone).
3
5
  *
@@ -6,19 +8,25 @@
6
8
  *
7
9
  * <kbDirName>/
8
10
  * ├── KnowledgeBase/ ← all team ontologies live here (the knowledge graph)
11
+ * ├── Groups/ ← one folder per group; each holds BOTH skills and tools
9
12
  * ├── Data/ ← agent-produced records; parsed like KnowledgeBase/
10
13
  * ├── Agents/ ← .agent files — agent role configurations (not the graph)
11
14
  * ├── Pipelines/ ← .pipeline files — execution-layer processes (not the graph)
12
- * ├── Skills/ ← reusable agent skills (NOT part of the graph)
13
- * ├── Tools/ ← user-authored tool manuals (*.tool; not the graph)
14
15
  * ├── roles.yaml ← identity → role mapping
15
16
  * └── access.md ← repo-root access-control rules
16
17
  *
18
+ * RESERVED IS NOT THE SAME AS CREATED. Core seeds the first two only
19
+ * (`CORE_REQUIRED_DIRS`); `Data/`, `Agents/` and `Pipelines/` scaffold an
20
+ * agentic execution layer that a distribution layers on. Their names stay here
21
+ * regardless, because reserving a name is what stops a KB that HAS the folder
22
+ * from having it treated as ordinary content — the file tree would otherwise
23
+ * fold it into Knowledge as a stray directory.
24
+ *
17
25
  * These names are the single source of truth for both sides of the app:
18
26
  * - Backend: the graph parser discovers ontologies under the
19
- * {@link ONTOLOGY_ROOTS} (`KnowledgeBase/` and `Data/`); `Skills/`,
20
- * `Tools/`, `Agents/`, `Pipelines/` (and anything else at the root) are
21
- * ignored by parsing, validation, and the diagram.
27
+ * {@link ONTOLOGY_ROOTS} (`KnowledgeBase/` and `Data/`); `Groups/`,
28
+ * `Agents/`, `Pipelines/` (and anything else at the root) are ignored by
29
+ * parsing, validation, and the diagram.
22
30
  * - Frontend: the file tree renders these root folders as distinct
23
31
  * top-level sections.
24
32
  *
@@ -28,16 +36,76 @@
28
36
  /** Folder under the repo root that contains all team ontologies. */
29
37
  export const KNOWLEDGE_BASE_DIR = 'KnowledgeBase';
30
38
 
31
- /** Folder under the repo root that holds reusable agent skills (not graph nodes). */
32
- export const SKILLS_DIR = 'Skills';
39
+ /**
40
+ * Folder under the repo root that holds the groups.
41
+ *
42
+ * Groups/<Group>/<skill>/SKILL.md a skill
43
+ * Groups/<Group>/<name>.tool a tool manual
44
+ * Groups/<Group>/access.md who can read/write the whole group
45
+ *
46
+ * One folder is one group. Skills and tools live TOGETHER inside it because
47
+ * they share a single access boundary: a tool a group cannot read is a skill
48
+ * that group cannot run, so splitting them across two roots meant maintaining
49
+ * the same permission twice and letting them drift.
50
+ *
51
+ * A group is not a registry of unique names — it is a folder. The same
52
+ * integration may exist in several groups as separate files (`Everyone/
53
+ * notion.tool` and `Finance/notion.tool`), each with its own credentials and
54
+ * its own access rule. That duplication is the design, not an accident.
55
+ */
56
+ export const GROUPS_DIR = 'Groups';
33
57
 
34
58
  /**
35
- * Folder under the repo root that holds user-authored tool manuals (`*.tool`
36
- * files). Each `.tool` is a UTCP manual (inline / http / mcp) that the MCP/UTCP
37
- * endpoint loads for any user who can read it (access-controlled like Skills).
38
- * Not part of the knowledge graph.
59
+ * The reserved name prefix marking a personal folder under `Groups/` —
60
+ * `Groups/personal-<user-id>/` is where a person's own skills live: created
61
+ * implicitly on their first personal skill, private by default (its seeded
62
+ * `access.md` names only its owner), and never listed as a group.
63
+ *
64
+ * The marker is STRUCTURAL on purpose: every surface that enumerates groups
65
+ * (catalog scan, sidebar, counts) filters on the name alone, with no access
66
+ * lookup needed, and the group-creation endpoint refuses names carrying the
67
+ * prefix — so a regular group can never squat on someone's personal folder.
68
+ */
69
+ export const PERSONAL_GROUP_PREFIX = 'personal-';
70
+
71
+ /**
72
+ * The one personal folder name for a user — keyed to the STABLE user id, not
73
+ * the email: emails change, and a folder keyed to one would be orphaned the
74
+ * day it does. Ids are opaque, so the name carries no PII into git history
75
+ * (which this platform never rewrites). `branchSegment` is THE segment
76
+ * sanitizer — the id lands in the folder name exactly as it lands in the
77
+ * user's suggestion-branch names, so the two spellings can never disagree.
78
+ */
79
+ export function personalGroupFolderName(userId: string): string {
80
+ return `${PERSONAL_GROUP_PREFIX}${branchSegment(userId)}`;
81
+ }
82
+
83
+ /** Whether a `Groups/` child is somebody's personal folder. */
84
+ export function isPersonalGroupFolder(folderName: string): boolean {
85
+ return folderName.startsWith(PERSONAL_GROUP_PREFIX);
86
+ }
87
+
88
+ /**
89
+ * The group a repo-root-relative path belongs to, or `null` for content that
90
+ * sits outside any group.
91
+ *
92
+ * Groups/GTM/heyreach-campaign/SKILL.md → 'GTM'
93
+ * Groups/GTM/heyreach.tool → 'GTM'
94
+ * Groups/loose-skill/SKILL.md → null (no group folder)
95
+ * KnowledgeBase/Product/… → null (not a group root)
96
+ *
97
+ * Returns null rather than throwing because a group is a property SOME paths
98
+ * have. Callers bucket by it; nothing requires it. An ungrouped skill is a
99
+ * real, supported state — the prototype calls those "yours alone".
39
100
  */
40
- export const TOOLS_DIR = 'Tools';
101
+ export function groupOfPath(repoRelativePath: string): string | null {
102
+ const segments = repoRelativePath.split('/').filter(Boolean);
103
+ if (segments[0] !== GROUPS_DIR) return null;
104
+ // Needs a segment for the group AND at least one below it, otherwise
105
+ // `Groups/GTM` (the folder itself) would report itself as being in a group,
106
+ // and a loose `Groups/slack.tool` would report a group named "slack.tool".
107
+ return segments.length >= 3 ? (segments[1] ?? null) : null;
108
+ }
41
109
 
42
110
  /**
43
111
  * Folder under the repo root for agent-produced records (pipeline instances,
@@ -69,7 +137,7 @@ export const ONTOLOGY_MARKERS = new Set([KNOWLEDGE_DIR, NODETYPE_DIR]);
69
137
 
70
138
  /**
71
139
  * A named ontology id, or `null` for the neutral bucket — content that belongs
72
- * to no named ontology (root config, `Skills/`, root-level `Knowledge/`, etc.).
140
+ * to no named ontology (root config, `Groups/`, root-level `Knowledge/`, etc.).
73
141
  * The named id is the repo-root-relative path of the ontology directory,
74
142
  * e.g. `KnowledgeBase/Product` or `KnowledgeBase/IT Architecture`.
75
143
  */