@bevel-software/platform-shared 0.4.1 → 0.5.1

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.
@@ -0,0 +1,102 @@
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
+ /** Group name → the kebab slug used in the join branch (lossy by design). */
41
+ export function kebabGroupName(group) {
42
+ return group
43
+ .trim()
44
+ .toLowerCase()
45
+ .replace(/\s+/g, '-')
46
+ .replace(/[^a-z0-9-]/g, '')
47
+ .replace(/-+/g, '-')
48
+ .replace(/^-|-$/g, '');
49
+ }
50
+ /** Length of each branch-name tag, in base-36 characters. */
51
+ const TAG_LENGTH = 7;
52
+ /**
53
+ * Short deterministic tag over an exact string (FNV-1a, base36).
54
+ *
55
+ * Synchronous and dependency-free so the same value is derivable in the
56
+ * browser and on the server; `crypto.subtle` is async and would force this
57
+ * whole convention to become promise-shaped for no benefit. Not a hash in the
58
+ * security sense and does not need to be — see the module note.
59
+ */
60
+ function shortTag(input) {
61
+ let hash = 0x811c9dc5;
62
+ for (let i = 0; i < input.length; i++) {
63
+ hash ^= input.charCodeAt(i);
64
+ // FNV prime, via shifts so the whole thing stays in 32-bit int math.
65
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
66
+ }
67
+ return hash.toString(36).padStart(TAG_LENGTH, '0').slice(-TAG_LENGTH);
68
+ }
69
+ /** The exact-group tag — recomputable from the group name alone. */
70
+ function groupTag(group) {
71
+ return shortTag(`g:${group}`);
72
+ }
73
+ /** The deterministic join branch for (requester, group). */
74
+ export function joinBranchFor(email, group) {
75
+ const normalizedEmail = email.trim().toLowerCase();
76
+ const localpart = normalizedEmail.split('@')[0] || normalizedEmail;
77
+ const slug = kebabGroupName(group);
78
+ const requester = shortTag(`u:${normalizedEmail}\n${group}`);
79
+ // A group whose name has no alphanumerics at all (`!!!`) kebabs to '' — the
80
+ // tags alone still yield a valid, matchable branch.
81
+ const middle = slug ? `${slug}-` : '';
82
+ return `${localpart}/join-${middle}${groupTag(group)}-${requester}`;
83
+ }
84
+ /**
85
+ * Does `branch` look like SOMEBODY's join branch for EXACTLY `group`?
86
+ *
87
+ * The group half (slug + `groupTag`) is recomputed and must match — this is
88
+ * what keeps slug-colliding groups (`Finance!` vs `finance`) from seeing,
89
+ * and worse settling, each other's requests. The requester half cannot be
90
+ * recomputed (the reader has no email) and matches by shape; the requester's
91
+ * identity comes from the change request's own attribution.
92
+ */
93
+ export function isJoinBranchFor(branch, group) {
94
+ const slug = kebabGroupName(group);
95
+ const middle = slug ? `${escapeRegExp(slug)}-` : '';
96
+ const requester = `[0-9a-z]{${TAG_LENGTH}}`;
97
+ return new RegExp(`^[^/]+/join-${middle}${groupTag(group)}-${requester}$`).test(branch);
98
+ }
99
+ function escapeRegExp(s) {
100
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
101
+ }
102
+ //# sourceMappingURL=join-request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"join-request.js","sourceRoot":"","sources":["../../src/workspace/join-request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,6EAA6E;AAC7E,MAAM,UAAU,cAAc,CAAC,KAAa;IAC1C,OAAO,KAAK;SACT,IAAI,EAAE;SACN,WAAW,EAAE;SACb,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;SACpB,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC;SAC1B,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC;SACnB,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;AAC3B,CAAC;AAED,6DAA6D;AAC7D,MAAM,UAAU,GAAG,CAAC,CAAC;AAErB;;;;;;;GAOG;AACH,SAAS,QAAQ,CAAC,KAAa;IAC7B,IAAI,IAAI,GAAG,UAAU,CAAC;IACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,IAAI,IAAI,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QAC5B,qEAAqE;QACrE,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC/F,CAAC;IACD,OAAO,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC;AACxE,CAAC;AAED,oEAAoE;AACpE,SAAS,QAAQ,CAAC,KAAa;IAC7B,OAAO,QAAQ,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC;AAChC,CAAC;AAED,4DAA4D;AAC5D,MAAM,UAAU,aAAa,CAAC,KAAa,EAAE,KAAa;IACxD,MAAM,eAAe,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACnD,MAAM,SAAS,GAAG,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,eAAe,CAAC;IACnE,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,SAAS,GAAG,QAAQ,CAAC,KAAK,eAAe,KAAK,KAAK,EAAE,CAAC,CAAC;IAC7D,4EAA4E;IAC5E,oDAAoD;IACpD,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACtC,OAAO,GAAG,SAAS,SAAS,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,SAAS,EAAE,CAAC;AACtE,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,MAAc,EAAE,KAAa;IAC3D,MAAM,IAAI,GAAG,cAAc,CAAC,KAAK,CAAC,CAAC;IACnC,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACpD,MAAM,SAAS,GAAG,YAAY,UAAU,GAAG,CAAC;IAC5C,OAAO,IAAI,MAAM,CAAC,eAAe,MAAM,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,SAAS,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC1F,CAAC;AAED,SAAS,YAAY,CAAC,CAAS;IAC7B,OAAO,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AAClD,CAAC"}
@@ -6,19 +6,25 @@
6
6
  *
7
7
  * <kbDirName>/
8
8
  * ├── KnowledgeBase/ ← all team ontologies live here (the knowledge graph)
9
+ * ├── Groups/ ← one folder per group; each holds BOTH skills and tools
9
10
  * ├── Data/ ← agent-produced records; parsed like KnowledgeBase/
10
11
  * ├── Agents/ ← .agent files — agent role configurations (not the graph)
11
12
  * ├── 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
13
  * ├── roles.yaml ← identity → role mapping
15
14
  * └── access.md ← repo-root access-control rules
16
15
  *
16
+ * RESERVED IS NOT THE SAME AS CREATED. Core seeds the first two only
17
+ * (`CORE_REQUIRED_DIRS`); `Data/`, `Agents/` and `Pipelines/` scaffold an
18
+ * agentic execution layer that a distribution layers on. Their names stay here
19
+ * regardless, because reserving a name is what stops a KB that HAS the folder
20
+ * from having it treated as ordinary content — the file tree would otherwise
21
+ * fold it into Knowledge as a stray directory.
22
+ *
17
23
  * These names are the single source of truth for both sides of the app:
18
24
  * - 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.
25
+ * {@link ONTOLOGY_ROOTS} (`KnowledgeBase/` and `Data/`); `Groups/`,
26
+ * `Agents/`, `Pipelines/` (and anything else at the root) are ignored by
27
+ * parsing, validation, and the diagram.
22
28
  * - Frontend: the file tree renders these root folders as distinct
23
29
  * top-level sections.
24
30
  *
@@ -26,15 +32,61 @@
26
32
  */
27
33
  /** Folder under the repo root that contains all team ontologies. */
28
34
  export declare const KNOWLEDGE_BASE_DIR = "KnowledgeBase";
29
- /** Folder under the repo root that holds reusable agent skills (not graph nodes). */
30
- export declare const SKILLS_DIR = "Skills";
31
35
  /**
32
- * Folder under the repo root that holds user-authored tool manuals (`*.tool`
33
- * files). Each `.tool` is a UTCP manual (inline / http / mcp) that the MCP/UTCP
34
- * endpoint loads for any user who can read it (access-controlled like Skills).
35
- * Not part of the knowledge graph.
36
+ * Folder under the repo root that holds the groups.
37
+ *
38
+ * Groups/<Group>/<skill>/SKILL.md a skill
39
+ * Groups/<Group>/<name>.tool a tool manual
40
+ * Groups/<Group>/access.md who can read/write the whole group
41
+ *
42
+ * One folder is one group. Skills and tools live TOGETHER inside it because
43
+ * they share a single access boundary: a tool a group cannot read is a skill
44
+ * that group cannot run, so splitting them across two roots meant maintaining
45
+ * the same permission twice and letting them drift.
46
+ *
47
+ * A group is not a registry of unique names — it is a folder. The same
48
+ * integration may exist in several groups as separate files (`Everyone/
49
+ * notion.tool` and `Finance/notion.tool`), each with its own credentials and
50
+ * its own access rule. That duplication is the design, not an accident.
51
+ */
52
+ export declare const GROUPS_DIR = "Groups";
53
+ /**
54
+ * The reserved name prefix marking a personal folder under `Groups/` —
55
+ * `Groups/personal-<user-id>/` is where a person's own skills live: created
56
+ * implicitly on their first personal skill, private by default (its seeded
57
+ * `access.md` names only its owner), and never listed as a group.
58
+ *
59
+ * The marker is STRUCTURAL on purpose: every surface that enumerates groups
60
+ * (catalog scan, sidebar, counts) filters on the name alone, with no access
61
+ * lookup needed, and the group-creation endpoint refuses names carrying the
62
+ * prefix — so a regular group can never squat on someone's personal folder.
63
+ */
64
+ export declare const PERSONAL_GROUP_PREFIX = "personal-";
65
+ /**
66
+ * The one personal folder name for a user — keyed to the STABLE user id, not
67
+ * the email: emails change, and a folder keyed to one would be orphaned the
68
+ * day it does. Ids are opaque, so the name carries no PII into git history
69
+ * (which this platform never rewrites). `branchSegment` is THE segment
70
+ * sanitizer — the id lands in the folder name exactly as it lands in the
71
+ * user's suggestion-branch names, so the two spellings can never disagree.
72
+ */
73
+ export declare function personalGroupFolderName(userId: string): string;
74
+ /** Whether a `Groups/` child is somebody's personal folder. */
75
+ export declare function isPersonalGroupFolder(folderName: string): boolean;
76
+ /**
77
+ * The group a repo-root-relative path belongs to, or `null` for content that
78
+ * sits outside any group.
79
+ *
80
+ * Groups/GTM/heyreach-campaign/SKILL.md → 'GTM'
81
+ * Groups/GTM/heyreach.tool → 'GTM'
82
+ * Groups/loose-skill/SKILL.md → null (no group folder)
83
+ * KnowledgeBase/Product/… → null (not a group root)
84
+ *
85
+ * Returns null rather than throwing because a group is a property SOME paths
86
+ * have. Callers bucket by it; nothing requires it. An ungrouped skill is a
87
+ * real, supported state — the prototype calls those "yours alone".
36
88
  */
37
- export declare const TOOLS_DIR = "Tools";
89
+ export declare function groupOfPath(repoRelativePath: string): string | null;
38
90
  /**
39
91
  * Folder under the repo root for agent-produced records (pipeline instances,
40
92
  * work items, intermediate outputs). Parsed exactly like `KnowledgeBase/`:
@@ -58,7 +110,7 @@ export declare const NODETYPE_DIR = "NodeTypes";
58
110
  export declare const ONTOLOGY_MARKERS: Set<string>;
59
111
  /**
60
112
  * A named ontology id, or `null` for the neutral bucket — content that belongs
61
- * to no named ontology (root config, `Skills/`, root-level `Knowledge/`, etc.).
113
+ * to no named ontology (root config, `Groups/`, root-level `Knowledge/`, etc.).
62
114
  * The named id is the repo-root-relative path of the ontology directory,
63
115
  * e.g. `KnowledgeBase/Product` or `KnowledgeBase/IT Architecture`.
64
116
  */
@@ -1 +1 @@
1
- {"version":3,"file":"kb-layout.d.ts","sourceRoot":"","sources":["../../src/workspace/kb-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAElD,qFAAqF;AACrF,eAAO,MAAM,UAAU,WAAW,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,SAAS,UAAU,CAAC;AAEjC;;;;GAIG;AACH,eAAO,MAAM,QAAQ,SAAS,CAAC;AAE/B,0GAA0G;AAC1G,eAAO,MAAM,UAAU,WAAW,CAAC;AAEnC,6GAA6G;AAC7G,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAAmC,CAAC;AAEhF,gFAAgF;AAChF,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC,qFAAqF;AACrF,eAAO,MAAM,YAAY,cAAc,CAAC;AAExC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,aAAyC,CAAC;AAEvE;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,IAAI,CAAC"}
1
+ {"version":3,"file":"kb-layout.d.ts","sourceRoot":"","sources":["../../src/workspace/kb-layout.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,kBAAkB,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,UAAU,WAAW,CAAC;AAEnC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,cAAc,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,+DAA+D;AAC/D,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOnE;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,SAAS,CAAC;AAE/B,0GAA0G;AAC1G,eAAO,MAAM,UAAU,WAAW,CAAC;AAEnC,6GAA6G;AAC7G,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,SAAS,MAAM,EAAmC,CAAC;AAEhF,gFAAgF;AAChF,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC,qFAAqF;AACrF,eAAO,MAAM,YAAY,cAAc,CAAC;AAExC,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,aAAyC,CAAC;AAEvE;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,IAAI,CAAC"}
@@ -1,3 +1,4 @@
1
+ import { branchSegment } from '../git/branchAuthor.js';
1
2
  /**
2
3
  * Top-level layout of the KB repo (inside the `KB_DIR_NAME` clone).
3
4
  *
@@ -6,19 +7,25 @@
6
7
  *
7
8
  * <kbDirName>/
8
9
  * ├── KnowledgeBase/ ← all team ontologies live here (the knowledge graph)
10
+ * ├── Groups/ ← one folder per group; each holds BOTH skills and tools
9
11
  * ├── Data/ ← agent-produced records; parsed like KnowledgeBase/
10
12
  * ├── Agents/ ← .agent files — agent role configurations (not the graph)
11
13
  * ├── 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
14
  * ├── roles.yaml ← identity → role mapping
15
15
  * └── access.md ← repo-root access-control rules
16
16
  *
17
+ * RESERVED IS NOT THE SAME AS CREATED. Core seeds the first two only
18
+ * (`CORE_REQUIRED_DIRS`); `Data/`, `Agents/` and `Pipelines/` scaffold an
19
+ * agentic execution layer that a distribution layers on. Their names stay here
20
+ * regardless, because reserving a name is what stops a KB that HAS the folder
21
+ * from having it treated as ordinary content — the file tree would otherwise
22
+ * fold it into Knowledge as a stray directory.
23
+ *
17
24
  * These names are the single source of truth for both sides of the app:
18
25
  * - 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.
26
+ * {@link ONTOLOGY_ROOTS} (`KnowledgeBase/` and `Data/`); `Groups/`,
27
+ * `Agents/`, `Pipelines/` (and anything else at the root) are ignored by
28
+ * parsing, validation, and the diagram.
22
29
  * - Frontend: the file tree renders these root folders as distinct
23
30
  * top-level sections.
24
31
  *
@@ -26,15 +33,73 @@
26
33
  */
27
34
  /** Folder under the repo root that contains all team ontologies. */
28
35
  export const KNOWLEDGE_BASE_DIR = 'KnowledgeBase';
29
- /** Folder under the repo root that holds reusable agent skills (not graph nodes). */
30
- export const SKILLS_DIR = 'Skills';
31
36
  /**
32
- * Folder under the repo root that holds user-authored tool manuals (`*.tool`
33
- * files). Each `.tool` is a UTCP manual (inline / http / mcp) that the MCP/UTCP
34
- * endpoint loads for any user who can read it (access-controlled like Skills).
35
- * Not part of the knowledge graph.
37
+ * Folder under the repo root that holds the groups.
38
+ *
39
+ * Groups/<Group>/<skill>/SKILL.md a skill
40
+ * Groups/<Group>/<name>.tool a tool manual
41
+ * Groups/<Group>/access.md who can read/write the whole group
42
+ *
43
+ * One folder is one group. Skills and tools live TOGETHER inside it because
44
+ * they share a single access boundary: a tool a group cannot read is a skill
45
+ * that group cannot run, so splitting them across two roots meant maintaining
46
+ * the same permission twice and letting them drift.
47
+ *
48
+ * A group is not a registry of unique names — it is a folder. The same
49
+ * integration may exist in several groups as separate files (`Everyone/
50
+ * notion.tool` and `Finance/notion.tool`), each with its own credentials and
51
+ * its own access rule. That duplication is the design, not an accident.
52
+ */
53
+ export const GROUPS_DIR = 'Groups';
54
+ /**
55
+ * The reserved name prefix marking a personal folder under `Groups/` —
56
+ * `Groups/personal-<user-id>/` is where a person's own skills live: created
57
+ * implicitly on their first personal skill, private by default (its seeded
58
+ * `access.md` names only its owner), and never listed as a group.
59
+ *
60
+ * The marker is STRUCTURAL on purpose: every surface that enumerates groups
61
+ * (catalog scan, sidebar, counts) filters on the name alone, with no access
62
+ * lookup needed, and the group-creation endpoint refuses names carrying the
63
+ * prefix — so a regular group can never squat on someone's personal folder.
64
+ */
65
+ export const PERSONAL_GROUP_PREFIX = 'personal-';
66
+ /**
67
+ * The one personal folder name for a user — keyed to the STABLE user id, not
68
+ * the email: emails change, and a folder keyed to one would be orphaned the
69
+ * day it does. Ids are opaque, so the name carries no PII into git history
70
+ * (which this platform never rewrites). `branchSegment` is THE segment
71
+ * sanitizer — the id lands in the folder name exactly as it lands in the
72
+ * user's suggestion-branch names, so the two spellings can never disagree.
73
+ */
74
+ export function personalGroupFolderName(userId) {
75
+ return `${PERSONAL_GROUP_PREFIX}${branchSegment(userId)}`;
76
+ }
77
+ /** Whether a `Groups/` child is somebody's personal folder. */
78
+ export function isPersonalGroupFolder(folderName) {
79
+ return folderName.startsWith(PERSONAL_GROUP_PREFIX);
80
+ }
81
+ /**
82
+ * The group a repo-root-relative path belongs to, or `null` for content that
83
+ * sits outside any group.
84
+ *
85
+ * Groups/GTM/heyreach-campaign/SKILL.md → 'GTM'
86
+ * Groups/GTM/heyreach.tool → 'GTM'
87
+ * Groups/loose-skill/SKILL.md → null (no group folder)
88
+ * KnowledgeBase/Product/… → null (not a group root)
89
+ *
90
+ * Returns null rather than throwing because a group is a property SOME paths
91
+ * have. Callers bucket by it; nothing requires it. An ungrouped skill is a
92
+ * real, supported state — the prototype calls those "yours alone".
36
93
  */
37
- export const TOOLS_DIR = 'Tools';
94
+ export function groupOfPath(repoRelativePath) {
95
+ const segments = repoRelativePath.split('/').filter(Boolean);
96
+ if (segments[0] !== GROUPS_DIR)
97
+ return null;
98
+ // Needs a segment for the group AND at least one below it, otherwise
99
+ // `Groups/GTM` (the folder itself) would report itself as being in a group,
100
+ // and a loose `Groups/slack.tool` would report a group named "slack.tool".
101
+ return segments.length >= 3 ? (segments[1] ?? null) : null;
102
+ }
38
103
  /**
39
104
  * Folder under the repo root for agent-produced records (pipeline instances,
40
105
  * work items, intermediate outputs). Parsed exactly like `KnowledgeBase/`:
@@ -1 +1 @@
1
- {"version":3,"file":"kb-layout.js","sourceRoot":"","sources":["../../src/workspace/kb-layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,oEAAoE;AACpE,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC;AAElD,qFAAqF;AACrF,MAAM,CAAC,MAAM,UAAU,GAAG,QAAQ,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,OAAO,CAAC;AAEjC;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE/B,0GAA0G;AAC1G,MAAM,CAAC,MAAM,UAAU,GAAG,QAAQ,CAAC;AAEnC,6GAA6G;AAC7G,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AAEzC;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAsB,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;AAEhF,gFAAgF;AAChF,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AAEzC,qFAAqF;AACrF,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC;AAExC,+EAA+E;AAC/E,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,YAAY,CAAC,CAAC,CAAC;AAUvE,8EAA8E;AAC9E,+EAA+E;AAC/E,2EAA2E;AAC3E,uEAAuE"}
1
+ {"version":3,"file":"kb-layout.js","sourceRoot":"","sources":["../../src/workspace/kb-layout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,oEAAoE;AACpE,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC;AAElD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,QAAQ,CAAC;AAEnC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,WAAW,CAAC;AAEjD;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CAAC,MAAc;IACpD,OAAO,GAAG,qBAAqB,GAAG,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;AAC5D,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,qBAAqB,CAAC,UAAkB;IACtD,OAAO,UAAU,CAAC,UAAU,CAAC,qBAAqB,CAAC,CAAC;AACtD,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,gBAAwB;IAClD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC7D,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,UAAU;QAAE,OAAO,IAAI,CAAC;IAC5C,qEAAqE;IACrE,4EAA4E;IAC5E,2EAA2E;IAC3E,OAAO,QAAQ,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE/B,0GAA0G;AAC1G,MAAM,CAAC,MAAM,UAAU,GAAG,QAAQ,CAAC;AAEnC,6GAA6G;AAC7G,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AAEzC;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAsB,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;AAEhF,gFAAgF;AAChF,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AAEzC,qFAAqF;AACrF,MAAM,CAAC,MAAM,YAAY,GAAG,WAAW,CAAC;AAExC,+EAA+E;AAC/E,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,YAAY,CAAC,CAAC,CAAC;AAUvE,8EAA8E;AAC9E,+EAA+E;AAC/E,2EAA2E;AAC3E,uEAAuE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-shared",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Shared types and pure domain utilities of the Bevel core platform (auth, workspace, git/workflow contracts, branch registry).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "dist",
17
- "src"
17
+ "src",
18
+ "THIRD-PARTY-NOTICES.md"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=22 <23"
@@ -28,7 +29,7 @@
28
29
  "license": "Apache-2.0",
29
30
  "repository": {
30
31
  "type": "git",
31
- "url": "git+https://github.com/Bevel-Software/skill-and-tool-management.git",
32
+ "url": "git+https://github.com/Bevel-Software/Hexis.git",
32
33
  "directory": "packages/shared"
33
34
  },
34
35
  "publishConfig": {
package/src/auth/types.ts CHANGED
@@ -1,16 +1,24 @@
1
- export interface AuthUser {
2
- id: string;
3
- email: string;
4
- name: string;
5
- avatarUrl?: string;
6
- }
7
-
8
- export interface LoginRequest {
9
- email: string;
10
- password: string;
11
- }
12
-
13
- export interface LoginResponse {
14
- token: string;
15
- user: AuthUser;
16
- }
1
+ export interface AuthUser {
2
+ id: string;
3
+ email: string;
4
+ name: string;
5
+ avatarUrl?: string;
6
+ /**
7
+ * Has this person concluded the connect-your-agent onboarding (welcome
8
+ * page Done, or dismissing the reminder pill). Optional so pre-existing
9
+ * fixtures and cached user objects stay valid; the server always sends it,
10
+ * and consumers treat only an explicit `false` as "still onboarding" — an
11
+ * absent field must never resurrect the welcome flow.
12
+ */
13
+ onboardingDone?: boolean;
14
+ }
15
+
16
+ export interface LoginRequest {
17
+ email: string;
18
+ password: string;
19
+ }
20
+
21
+ export interface LoginResponse {
22
+ token: string;
23
+ user: AuthUser;
24
+ }
@@ -44,3 +44,40 @@ export function isBranchAuthoredBy(
44
44
  if (!localpart) return false;
45
45
  return branchName.startsWith(`${localpart}/`);
46
46
  }
47
+
48
+ /** Keep only characters git branch segments accept; collapse the rest to '-'. */
49
+ export function branchSegment(raw: string): string {
50
+ const cleaned = raw
51
+ .toLowerCase()
52
+ .replace(/[^a-z0-9._-]+/g, '-')
53
+ .replace(/^[-.]+/, '')
54
+ .replace(/[.]+$/, '');
55
+ return cleaned || 'user';
56
+ }
57
+
58
+ /**
59
+ * The `suggestions/<who>-<id8>/` prefix every one of this user's suggestion
60
+ * branches carries (`…/knowledge` for the Knowledge bundle, a skill segment
61
+ * for skill proposals). SHARED so the client that names the branch and the
62
+ * server that judges "may this caller delete it" can never disagree. The
63
+ * user-id slice is what makes the identity collision-proof — `branchSegment`
64
+ * alone is lossy (`alex+ops@…` and `alex-ops@…` both clean to `alex-ops`).
65
+ */
66
+ export function suggestionsBranchPrefixFor(user: { email: string; id: string }): string {
67
+ const who = branchSegment(user.email.split('@')[0]);
68
+ const id = branchSegment(user.id).slice(0, 8) || 'user';
69
+ return `suggestions/${who}-${id}/`;
70
+ }
71
+
72
+ /**
73
+ * True when `branchName` is one of THIS user's suggestion branches — the
74
+ * authorship rule for the `suggestions/…` namespace, where the plain
75
+ * `<localpart>/` convention of {@link isBranchAuthoredBy} does not apply.
76
+ */
77
+ export function isOwnSuggestionsBranch(
78
+ branchName: string,
79
+ user: { email: string | null | undefined; id: string | null | undefined },
80
+ ): boolean {
81
+ if (!user.email || !user.id) return false;
82
+ return branchName.startsWith(suggestionsBranchPrefixFor({ email: user.email, id: user.id }));
83
+ }
@@ -1,40 +1,24 @@
1
1
  /**
2
- * Authoritative, env-overridable registry of the default branch + the set of
3
- * protected branch slugs in the KB repo, plus their user-visible display names.
2
+ * The default branch + the set of protected branch slugs in the KB repo, and
3
+ * their user-visible display names. Single source of truth for BOTH sides of
4
+ * the app, so backend enforcement and frontend affordances can never drift.
4
5
  *
5
- * Single source of truth for BOTH sides of the app:
6
- * - Backend (Node) reads `process.env.*` at runtime — the values are populated
7
- * from the environment / the `.env` loaded by `config.ts`, which runs before
8
- * this module is first evaluated (see `backend/src/index.ts` import order).
9
- * - Frontend (browser) can't read `process.env` at runtime, so Vite statically
10
- * replaces the `process.env.DEFAULT_BRANCH` / `process.env.PROTECTED_BRANCHES`
11
- * references at build time via `define` in `frontend/vite.config.ts`. Keep
12
- * those references written as the literal `process.env.<NAME>` member access
13
- * so the static replacement keeps matching.
6
+ * CONFIGURED, NOT IMPORTED. This module used to read `process.env` at import
7
+ * time and throw when it found nothing — which made the branch model something
8
+ * a deployment had to know before any code could load. On the frontend that
9
+ * meant baking it into the bundle at build time (Vite `define`), so the same
10
+ * artifact could not serve two deployments and changing a branch name meant a
11
+ * rebuild. Both sides now CALL {@link configureBranchModel} during boot:
12
+ * the backend from its environment, the browser from `GET /api/config`.
14
13
  *
15
- * Both the backend (server-side enforcement — refusing commit/push/revert and
16
- * rejecting `createBranch` on protected names) and the frontend (disabling UI
17
- * actions, showing the protected-branch banner, branch sort order) consume this
18
- * single table so the two can never drift. Don't re-declare or hard-code the
19
- * names elsewhere.
20
- *
21
- * Env vars (both REQUIRED — no fallback; the module throws at load if either is
22
- * missing/empty, so a misconfigured deploy fails fast instead of silently
23
- * defaulting to the wrong branches). Note the frontend needs them present at
24
- * BUILD time (Vite `define`), the backend at runtime:
25
- * - `DEFAULT_BRANCH` — the branch a user lands on / the default propose
26
- * target.
27
- * - `PROTECTED_BRANCHES` — comma/space-separated list of protected slugs.
28
- * Display names are auto-derived from each slug
29
- * (`target-company-state` → "Target company state").
14
+ * The exported bindings stay bindings — `DEFAULT_BRANCH` is still imported and
15
+ * read exactly as before at every call site. ES module live bindings mean a
16
+ * reader inside a function body sees whatever configuration has been applied by
17
+ * the time it runs. What does NOT work is capturing one at module scope
18
+ * (`const X = DEFAULT_BRANCH` in a file's top level), which snapshots the value
19
+ * at import — before configuration. Use a function there instead.
30
20
  */
31
21
 
32
- // `process` is supplied by Node at runtime on the backend and statically
33
- // replaced by Vite at build time on the frontend (vite.config.ts `define`).
34
- // Declared locally — narrowed to just `env` — so this shared module typechecks
35
- // without pulling @types/node into the @bevel-software/platform-shared package. On the backend
36
- // build (which has @types/node) this module-scoped declaration simply shadows
37
- // the global `process`, which is harmless here.
38
22
  declare const process: { env: Record<string, string | undefined> };
39
23
 
40
24
  function parseBranchList(raw: string | undefined): string[] {
@@ -57,42 +41,89 @@ function deriveDisplayName(slug: string): string {
57
41
 
58
42
  /**
59
43
  * The branch a logged-in user lands on when they don't explicitly pick one, and
60
- * the default destination for shared drafts. Env-overridable via `DEFAULT_BRANCH`.
44
+ * the default destination for shared drafts.
45
+ *
46
+ * Empty until {@link configureBranchModel} runs. Read it inside a function, not
47
+ * at module scope — see this file's header.
61
48
  */
62
- export const DEFAULT_BRANCH: string = (process.env.DEFAULT_BRANCH ?? '').trim();
63
- if (!DEFAULT_BRANCH) {
64
- throw new Error(
65
- 'DEFAULT_BRANCH env var is required (no fallback). Set it on the backend ' +
66
- 'runtime and at the frontend build (Vite reads it via define).',
67
- );
49
+ export let DEFAULT_BRANCH: string = '';
50
+
51
+ export let PROTECTED_BRANCH_DISPLAY_NAMES: Readonly<Record<string, string>> = Object.freeze({});
52
+
53
+ export let PROTECTED_BRANCHES: ReadonlySet<string> = new Set<string>();
54
+
55
+ /** The shape both sides configure from, and the shape `/api/config` serves. */
56
+ export interface BranchModel {
57
+ defaultBranch: string;
58
+ /** Slugs; a comma/space-separated string is accepted for env convenience. */
59
+ protectedBranches: string[] | string;
68
60
  }
69
61
 
70
- const PROTECTED_BRANCH_LIST: string[] = parseBranchList(process.env.PROTECTED_BRANCHES);
71
- if (PROTECTED_BRANCH_LIST.length === 0) {
72
- throw new Error(
73
- 'PROTECTED_BRANCHES env var is required (no fallback). Provide a ' +
74
- 'comma/space-separated list of protected branch slugs.',
75
- );
62
+ /**
63
+ * Apply the branch model. Called once during boot on each side, and validated
64
+ * here rather than at either call site so the two cannot disagree about what
65
+ * counts as a valid pair.
66
+ */
67
+ export function branchListOf(model: BranchModel): string[] {
68
+ return Array.isArray(model.protectedBranches)
69
+ ? model.protectedBranches.map((s) => s.trim()).filter(Boolean)
70
+ : parseBranchList(model.protectedBranches);
76
71
  }
77
- // The default branch is where users land and the default propose target — it
78
- // must itself be protected, or `isProtectedBranch(DEFAULT_BRANCH)` is false and
79
- // the protected-branch guards silently don't apply to it. Fail fast on a
80
- // misconfigured pair rather than shipping that inconsistency.
81
- if (!PROTECTED_BRANCH_LIST.includes(DEFAULT_BRANCH)) {
82
- throw new Error(
83
- `DEFAULT_BRANCH ("${DEFAULT_BRANCH}") must be one of PROTECTED_BRANCHES ` +
84
- `(${PROTECTED_BRANCH_LIST.join(', ')}).`,
85
- );
72
+
73
+ /**
74
+ * What is wrong with a pair, or null when nothing is — the same rule
75
+ * {@link configureBranchModel} enforces, without applying anything.
76
+ *
77
+ * Separate because the setup screen has to VALIDATE a proposed pair before it
78
+ * is saved, and applying a model as a side effect of checking it would swap the
79
+ * running app onto branches nobody has confirmed yet.
80
+ */
81
+ export function validateBranchModel(model: BranchModel): string | null {
82
+ const defaultBranch = (model.defaultBranch ?? '').trim();
83
+ const list = branchListOf(model);
84
+ if (!defaultBranch) return 'A default branch is required — the branch users land on.';
85
+ if (list.length === 0) return 'At least one protected branch is required.';
86
+ // The default branch is where users land and the default propose target — it
87
+ // must itself be protected, or `isProtectedBranch(DEFAULT_BRANCH)` is false
88
+ // and the protected-branch guards silently do not apply to it. Refuse the
89
+ // pair rather than ship that inconsistency.
90
+ if (!list.includes(defaultBranch)) {
91
+ return (
92
+ `The default branch ("${defaultBranch}") must be one of the protected branches ` +
93
+ `(${list.join(', ')}).`
94
+ );
95
+ }
96
+ return null;
86
97
  }
87
98
 
88
- export const PROTECTED_BRANCH_DISPLAY_NAMES: Readonly<Record<string, string>> =
89
- Object.freeze(
90
- Object.fromEntries(
91
- PROTECTED_BRANCH_LIST.map((slug) => [slug, deriveDisplayName(slug)]),
92
- ),
99
+ export function configureBranchModel(model: BranchModel): void {
100
+ const problem = validateBranchModel(model);
101
+ if (problem) throw new Error(problem);
102
+ const defaultBranch = model.defaultBranch.trim();
103
+ const list = branchListOf(model);
104
+
105
+ DEFAULT_BRANCH = defaultBranch;
106
+ PROTECTED_BRANCH_DISPLAY_NAMES = Object.freeze(
107
+ Object.fromEntries(list.map((slug) => [slug, deriveDisplayName(slug)])),
93
108
  );
109
+ PROTECTED_BRANCHES = new Set(list);
110
+ }
94
111
 
95
- export const PROTECTED_BRANCHES: ReadonlySet<string> = new Set(PROTECTED_BRANCH_LIST);
112
+ /** Whether {@link configureBranchModel} has run — the frontend gates render on this. */
113
+ export function isBranchModelConfigured(): boolean {
114
+ return DEFAULT_BRANCH !== '';
115
+ }
116
+
117
+ /**
118
+ * The model as the environment describes it. Node-side only; the browser has no
119
+ * `process.env` to read and is served the same shape by `GET /api/config`.
120
+ */
121
+ export function branchModelFromEnv(): BranchModel {
122
+ return {
123
+ defaultBranch: (process.env.DEFAULT_BRANCH ?? '').trim(),
124
+ protectedBranches: process.env.PROTECTED_BRANCHES ?? '',
125
+ };
126
+ }
96
127
 
97
128
  export function isProtectedBranch(name: string | null | undefined): boolean {
98
129
  return !!name && Object.prototype.hasOwnProperty.call(