@bevel-software/platform-shared 0.25.2 → 0.27.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 (38) hide show
  1. package/dist/git/pr.types.d.ts +101 -0
  2. package/dist/git/pr.types.d.ts.map +1 -1
  3. package/dist/git/types.d.ts +76 -2
  4. package/dist/git/types.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/workflow/events.d.ts +24 -1
  10. package/dist/workflow/events.d.ts.map +1 -1
  11. package/dist/workflow/events.js +1 -0
  12. package/dist/workflow/events.js.map +1 -1
  13. package/dist/workflow/interface.d.ts +42 -1
  14. package/dist/workflow/interface.d.ts.map +1 -1
  15. package/dist/workflow/types.d.ts +57 -3
  16. package/dist/workflow/types.d.ts.map +1 -1
  17. package/dist/workspace/kb-layout.d.ts +40 -101
  18. package/dist/workspace/kb-layout.d.ts.map +1 -1
  19. package/dist/workspace/kb-layout.js +40 -159
  20. package/dist/workspace/kb-layout.js.map +1 -1
  21. package/dist/workspace/md-links.d.ts +138 -0
  22. package/dist/workspace/md-links.d.ts.map +1 -0
  23. package/dist/workspace/md-links.js +703 -0
  24. package/dist/workspace/md-links.js.map +1 -0
  25. package/dist/workspace/platform-files.d.ts +25 -24
  26. package/dist/workspace/platform-files.d.ts.map +1 -1
  27. package/dist/workspace/platform-files.js +41 -25
  28. package/dist/workspace/platform-files.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/git/pr.types.ts +99 -0
  31. package/src/git/types.ts +80 -2
  32. package/src/index.ts +1 -0
  33. package/src/workflow/events.ts +26 -0
  34. package/src/workflow/interface.ts +42 -0
  35. package/src/workflow/types.ts +48 -4
  36. package/src/workspace/kb-layout.ts +39 -172
  37. package/src/workspace/md-links.ts +757 -0
  38. package/src/workspace/platform-files.ts +44 -26
@@ -33,6 +33,8 @@ import type {
33
33
  ChangeRequestUpdateResult,
34
34
  ChangeRequestState,
35
35
  ChangedFile,
36
+ DeleteBranchPreview,
37
+ DeleteBranchResult,
36
38
  FileApproval,
37
39
  FileLock,
38
40
  FolderChangeRequest,
@@ -76,6 +78,28 @@ export interface IWorkflowService {
76
78
  user: AuthUser,
77
79
  opts?: { onlyIfNoRemote?: boolean },
78
80
  ): Promise<void>;
81
+ /**
82
+ * An agent's `delete_branch`: the app's author-or-Admin rule, plus the
83
+ * guards an agent needs and a person in the branch switcher does not.
84
+ * Refuses a protected branch, a name that is not a branch, a branch an open
85
+ * change request that still proposes something (or whose changes cannot be
86
+ * determined) comes from or goes into, a branch whose checkout still has
87
+ * saves landing or a file held, and — unless `discardUnmerged` — one holding
88
+ * commits that are not on the default branch. Open requests that propose
89
+ * nothing block nothing: they are closed only once every other check has
90
+ * passed, just before the branch is removed — never by a preview or a
91
+ * refused deletion. Fetches first, strictly: when the shared repository
92
+ * cannot be reached it refuses. Runs from the default branch's workspace,
93
+ * whichever workspace the caller is in. `dryRun` reports what a deletion
94
+ * would do and changes nothing. `maySee` is the caller's view of change
95
+ * requests: one it answers no for is left out of the preview and refused on
96
+ * without its number or link. Absent, every request is visible.
97
+ */
98
+ deleteBranchChecked(
99
+ user: AuthUser,
100
+ name: string,
101
+ opts?: { dryRun?: boolean; discardUnmerged?: boolean; maySee?: (number: number) => Promise<boolean> },
102
+ ): Promise<DeleteBranchPreview | DeleteBranchResult>;
79
103
  // `switchBranch` removed: under the per-branch workspace model the active
80
104
  // branch is the workspace's identity. Switching branches is a workspace
81
105
  // selection (`WorkspaceService.getOrCreateForBranch`), not an operation
@@ -437,6 +461,17 @@ export interface IWorkflowService {
437
461
  // ── Change Requests ───────────────────────────────────────────────────────
438
462
 
439
463
  listChangeRequests(opts?: { fresh?: boolean }): Promise<ChangeRequest[]>;
464
+ /**
465
+ * Change requests in ANY of `states`, newest first. `listChangeRequests`
466
+ * answers the open ones only — the app's lists are all about what is still
467
+ * being decided — so a reader catching up on what HAPPENED (the agent read
468
+ * tools' `state: closed` / `state: all`) needs this one.
469
+ */
470
+ listChangeRequestsByState(
471
+ states: ChangeRequestState[],
472
+ /** `workspaceId`: the clone to read file lists in; any clone will do, and a caller that resolved one passes it so the list and the by-number reads agree. */
473
+ opts?: { fresh?: boolean; workspaceId?: string },
474
+ ): Promise<ChangeRequest[]>;
440
475
  /** Change requests authored by the given user (matched on stored author identity). */
441
476
  listChangeRequestsAuthoredBy(
442
477
  emailOrLogin: string,
@@ -681,6 +716,13 @@ export interface IWorkflowService {
681
716
  * merge resets it to the published tip, which would discard them.
682
717
  *
683
718
  * Conflicts write nothing and come back as `conflicts-need-resolution`.
719
+ *
720
+ * Writes are committed asynchronously, so before merging this waits — up to
721
+ * 20 seconds, holding no lock — for `sourceBranch` to have no queued commit.
722
+ * Still queued after that, or a queued commit escalated to a person, comes
723
+ * back as `pending-commits` with nothing merged. A target that already held
724
+ * everything on the source comes back as `nothing-to-merge` with its tip;
725
+ * `merged` means a merge commit was made.
684
726
  */
685
727
  mergeBranch(user: AuthUser, sourceBranch: string, targetBranch: string): Promise<MergeBranchOutcome>;
686
728
  }
@@ -215,13 +215,57 @@ export type MergeChangeRequestOutcome =
215
215
  | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] };
216
216
 
217
217
  /**
218
- * What a direct branch-to-branch merge (`mergeBranch`) did: landed as `sha`
219
- * on the target (the target's own tip when it already contained the source),
220
- * or stopped on conflicts, nothing written.
218
+ * What a direct branch-to-branch merge (`mergeBranch`) did:
219
+ *
220
+ * - `merged` — a merge commit `sha` was made and published
221
+ * on the target.
222
+ * - `nothing-to-merge` — the target already contained the source;
223
+ * `sha` is the target's unchanged tip.
224
+ * - `conflicts-need-resolution` — stopped on conflicts, nothing written.
225
+ * - `pending-commits` — writes on the source are still being
226
+ * committed (the merge waited for them and
227
+ * they did not land in time), or one of them
228
+ * failed and `needsAttention` carries the
229
+ * worker's message. Nothing was merged.
221
230
  */
222
231
  export type MergeBranchOutcome =
223
232
  | { kind: 'merged'; sha: string }
224
- | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] };
233
+ | { kind: 'nothing-to-merge'; sha: string }
234
+ | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] }
235
+ | { kind: 'pending-commits'; branch: string; pending: number; needsAttention?: string; message: string };
236
+
237
+ /**
238
+ * What deleting a branch would do, as an agent's `delete_branch` preview
239
+ * (`dryRun`) reports it. It reserves nothing: every check runs again when the
240
+ * deletion is asked for.
241
+ *
242
+ * - `exists` — the branch is on the shared repository (or on the server).
243
+ * - `canDelete` — a deletion asked for now, by this caller, would go
244
+ * through; `refusals` says why not when it would not.
245
+ * - `unmergedCommits` — commits on the branch that are not on the default branch.
246
+ * - `openChangeRequests` — open change requests from (`source`) or into (`target`) it,
247
+ * with their link; one that `proposesNothing` would be
248
+ * closed by the deletion and blocks nothing.
249
+ * - `lastCommit` — the branch's tip; null when it does not exist.
250
+ */
251
+ export interface DeleteBranchPreview {
252
+ kind: 'preview';
253
+ branch: string;
254
+ exists: boolean;
255
+ canDelete: boolean;
256
+ refusals: string[];
257
+ unmergedCommits: number;
258
+ openChangeRequests: { number: number; end: 'source' | 'target'; url: string; proposesNothing: boolean }[];
259
+ lastCommit: string | null;
260
+ }
261
+
262
+ /** A completed deletion: the tip it had, so it can be restored, and how many commits `discardUnmerged` threw away. */
263
+ export interface DeleteBranchResult {
264
+ kind: 'deleted';
265
+ branch: string;
266
+ lastCommit: string;
267
+ discardedCommits: number;
268
+ }
225
269
 
226
270
  /**
227
271
  * What a remote sync did to one branch's clone. One entry per branch in the
@@ -111,31 +111,30 @@ export let SKILLS_DIR = 'Skills';
111
111
  export let PLUGINS_DIR = 'Plugins';
112
112
 
113
113
  /**
114
- * The file name of the platform's MANAGED agent guide at the repository root —
115
- * the document every connected agent is told to read first, written and
116
- * refreshed from the packaged template on every start.
117
- *
118
- * Configurable for one reason: `AGENTS.md` is the name coding agents look for
119
- * by convention, so a customer arriving with a repository of their own very
120
- * often already HAS one, and under the default name the platform would
121
- * overwrite it on the first boot and on every boot after. Renaming the managed
122
- * guide (`HEXIS.md`, say) hands that name back: `AGENTS.md` becomes ordinary
123
- * content the platform never writes, never refreshes and never hides, and the
124
- * customer's file is the one that points at ours (see
125
- * {@link agentsFilePointerSentence}).
126
- *
127
- * A live binding like the three roots above — read it inside a function body,
128
- * never capture it at module scope.
114
+ * The name the platform's agent guide is READ BY at the repository root — the
115
+ * document every connected agent is told to read first.
116
+ *
117
+ * The guide is no longer a file: the backend composes it from text the code
118
+ * owns and serves it through `get_agent_guide` and through a `read_file` of
119
+ * this name (see core-backend's `modules/agent-guide`), after the knowledge
120
+ * base's own file of that name when it has one. ONE name on every
121
+ * deployment: the setting that let a deployment rename the written guide is
122
+ * gone, and the settings layer no longer reads a value it saved — the layout
123
+ * it produces carries this name. The field stays on the layout for the shape
124
+ * every consumer reads, and the helpers here (`agentsFileOf`,
125
+ * `configureKbLayout`) still apply whatever a caller puts in it, so the one
126
+ * name is the producer's doing, not this module's.
129
127
  */
130
128
  export let AGENTS_FILE = 'AGENTS.md';
131
129
 
132
130
  /**
133
- * The name the managed guide had when it was the only name it could have.
131
+ * The name the guide had when it was the only name it could have, and the
132
+ * name it is always read by now.
134
133
  *
135
134
  * Referenced ONLY by the code that has to tell OUR file from THEIRS — the
136
- * boot-time removal of a platform-written `AGENTS.md`, the ignore rule that
137
- * stops hiding it, the instruction telling an agent to read the customer's
138
- * file too. In the spirit of {@link LEGACY_GROUPS_DIR}: a second live spelling
135
+ * boot-time removal of the guide copies earlier releases wrote, the ignore
136
+ * rule that stops hiding them, the instruction telling an agent what to read
137
+ * first. In the spirit of {@link LEGACY_GROUPS_DIR}: a second live spelling
139
138
  * of the CURRENT name is how two layouts start being supported by accident, so
140
139
  * this one is a constant and means exactly one thing.
141
140
  */
@@ -143,11 +142,11 @@ export const LEGACY_AGENTS_FILE = 'AGENTS.md';
143
142
 
144
143
  /**
145
144
  * The platform files whose names are FIXED — the ones no deployment renames.
146
- * The guide is the fourth platform file and is deliberately absent here: its
147
- * name is {@link AGENTS_FILE}, and `platform-files.ts` composes the two into
148
- * the set every gate reads.
145
+ * The guide is NOT a platform file: nothing is written under its name, so a
146
+ * root `AGENTS.md` (or whatever the deployment once called the guide) is the
147
+ * organisation's own conventions page, movable and deletable like any other.
149
148
  *
150
- * It lives in this module rather than beside that composition because the
149
+ * It lives in this module rather than beside `platform-files.ts` because the
151
150
  * LAYOUT has to validate against it (a guide may not be called `access.md`),
152
151
  * and `platform-files.ts` already reads this module — the other direction
153
152
  * would be a cycle.
@@ -196,139 +195,13 @@ export function agentsFileOf(layout: KbLayout): string {
196
195
  * a leading `#` is a comment and a leading `!` a negation (the rule would
197
196
  * silently hide nothing), `[`, `]`, `*` and `?` are globs, and a backslash is
198
197
  * the escape itself. Each is escaped so the pattern names exactly the file.
198
+ * Read by the startup step that takes the platform's own rule for the guide
199
+ * OUT of an ignore file an earlier release wrote it into.
199
200
  */
200
201
  export function gitignoreLiteral(name: string): string {
201
202
  return name.replace(/[[\]*?\\]/g, '\\$&').replace(/^([#!])/, '\\$1');
202
203
  }
203
204
 
204
- /**
205
- * The ONE sentence the platform offers to keep in a customer's own
206
- * `AGENTS.md`, pointing at the managed guide beside it.
207
- *
208
- * Defined here, once, because two surfaces must produce the identical text:
209
- * the deployment-settings field previews it before the admin consents, and the
210
- * startup step appends it. A sentence written twice is a sentence that drifts,
211
- * and a drifted one appends a SECOND copy to every customer file on the boot
212
- * after the drift — which is the one thing this whole feature exists to stop.
213
- *
214
- * A guide name is a FILE NAME, not an identifier: everything `validateFilename`
215
- * admits can appear in it — spaces, brackets, parentheses, `#`, `%` — and each
216
- * of those means something in an inline link. So the link is BUILT rather than
217
- * interpolated: the label backslash-escaped ({@link markdownLinkLabel}), the
218
- * destination percent-encoded ({@link agentsFileLinkPath}). A name that only
219
- * parenthesised would break the destination; `#` would turn the rest of the
220
- * name into a URL fragment, and the link would point at the customer's own
221
- * file.
222
- *
223
- * Neither spelling need match the name as it is on disk, so nothing may ask
224
- * whether this sentence is present by searching for the RAW name — see
225
- * {@link mentionsAgentsFile}, which is how the startup step asks.
226
- */
227
- export function agentsFilePointerSentence(agentsFile: string): string {
228
- return `Read [${markdownLinkLabel(agentsFile)}](${agentsFileLinkPath(agentsFile)})${POINTER_SENTENCE_TAIL}`;
229
- }
230
-
231
- /**
232
- * Everything of the sentence that does NOT depend on the guide's name — split
233
- * out so the one definition above can also be RECOGNISED, by the pattern below,
234
- * when the name it was written with is no longer the name in effect.
235
- */
236
- const POINTER_SENTENCE_TAIL =
237
- " before working in this knowledge base — it is the platform's guide to its layout, files and rules.";
238
-
239
- /**
240
- * A pointer sentence the platform wrote, naming ANY guide.
241
- *
242
- * The label admits a backslash escape (`\[`, `\]`) because that is what
243
- * {@link markdownLinkLabel} puts there; the destination cannot contain a `)`
244
- * or a newline because {@link agentsFileLinkPath} encodes both. Anchored at
245
- * both ends by text the platform fixed, so the shape is the provenance —
246
- * a customer would have to reproduce our sentence word for word to be taken
247
- * for us, which is the same bar the managed-guide header sets.
248
- */
249
- const POINTER_SENTENCE_PATTERN = new RegExp(
250
- `Read \\[(?:[^\\]\\n\\\\]|\\\\[\\s\\S])*\\]\\(\\.\\/[^)\\n]*\\)${escapeRegExp(POINTER_SENTENCE_TAIL)}`,
251
- 'g',
252
- );
253
-
254
- /** `text` as a literal inside a regular expression. */
255
- function escapeRegExp(text: string): string {
256
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
257
- }
258
-
259
- /**
260
- * `text` with every pointer sentence THE PLATFORM WROTE aimed at `agentsFile`
261
- * — or null when it holds none of ours.
262
- *
263
- * This is what a SECOND rename needs. The guide's name is a setting an admin
264
- * may change again: a knowledge base whose `AGENTS.md` was given a sentence
265
- * pointing at `HEXIS.md` and is then renamed to `GUIDE.md` must have that
266
- * sentence aimed at the new file, not a second one appended beneath a first
267
- * that now points at nothing. Asking only whether the NEW name is mentioned
268
- * cannot see that — the old sentence does not mention it.
269
- *
270
- * Returning the text unchanged (rather than null) when the sentence is already
271
- * right is deliberate: "ours and correct" and "not ours at all" are different
272
- * answers, and only the caller knows that the second one means "consider
273
- * appending".
274
- */
275
- export function retargetAgentsFilePointer(text: string, agentsFile: string): string | null {
276
- let found = false;
277
- const wanted = agentsFilePointerSentence(agentsFile);
278
- const updated = text.replace(POINTER_SENTENCE_PATTERN, () => {
279
- found = true;
280
- return wanted;
281
- });
282
- return found ? updated : null;
283
- }
284
-
285
- /**
286
- * `text` as an inline link's LABEL: the characters that would end the label or
287
- * start emphasis or code inside it, backslash-escaped. Nothing else is touched
288
- * — a filename is read by people, and `AGENTS\.md` helps no one.
289
- */
290
- function markdownLinkLabel(text: string): string {
291
- return text.replace(/[\\[\]`*_]/g, (c) => `\\${c}`);
292
- }
293
-
294
- /**
295
- * The guide as an inline link's DESTINATION: `./` and the name, percent-encoded.
296
- *
297
- * `encodeURI` does most of it (a space, a bracket, and `%` itself, so an
298
- * already-encoded-looking name is not decoded by a reader). Three more are
299
- * encoded by hand because `encodeURI` leaves them and each one ENDS the path
300
- * early: `#` opens a fragment, and `(`/`)` close the destination in
301
- * CommonMark's bare form. `?` and the rest of the URL-significant set are
302
- * already refused by {@link validateFilename}.
303
- *
304
- * An ordinary name has none of these and comes out exactly as it went in.
305
- */
306
- function agentsFileLinkPath(agentsFile: string): string {
307
- return `./${encodeURI(agentsFile).replace(/[#()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
308
- }
309
-
310
- /**
311
- * Whether `text` already points at the guide — the ONE question the startup
312
- * step asks before appending {@link agentsFilePointerSentence} to a customer's
313
- * own `AGENTS.md`.
314
- *
315
- * The plain name is the answer that matters: a mention in the customer's own
316
- * words, a heading, a link they wrote, all count, and the platform stays out
317
- * of a file it does not own. The other two spellings are the platform's OWN,
318
- * and they are here for idempotence: the sentence writes the name escaped in
319
- * the label and encoded in the destination, so on a punctuated name the file
320
- * the last boot wrote need not contain the raw name at all. Asking only for
321
- * that one would append a second copy on the next boot, and a third on the
322
- * one after — the exact failure this feature exists to prevent.
323
- */
324
- export function mentionsAgentsFile(text: string, agentsFile: string): boolean {
325
- return (
326
- text.includes(agentsFile) ||
327
- text.includes(markdownLinkLabel(agentsFile)) ||
328
- text.includes(agentsFileLinkPath(agentsFile))
329
- );
330
- }
331
-
332
205
  /**
333
206
  * What is wrong with one root name, or null. A root is joined onto the repo
334
207
  * root and onto `<dir>/.gitkeep`, so a separator or `..` would write outside
@@ -350,22 +223,16 @@ export function validateKbRootName(name: string): string | null {
350
223
  }
351
224
 
352
225
  /**
353
- * What is wrong with the agent guide's file name, or null.
354
- *
355
- * The rules, and what each one is for:
226
+ * What is wrong with the agent guide's name, or null. The rules date from when
227
+ * the guide was written to disk under this name; a saved name still has to be
228
+ * one the read tools can answer to, so they stand:
356
229
  *
357
- * - ONE FILE NAME. The name is joined onto the repository root and read from
358
- * there and nowhere else, so a separator would name a file the platform
359
- * would write but never read back.
360
- * - A MARKDOWN NAME. The guide is a markdown document that people open in the
361
- * app and agents read as text; `.md` is also what the per-file access rules
362
- * apply to, so a guide under any other extension would take its folder's
363
- * rules and stop being readable by everyone.
364
- * - NOT `CLAUDE.md`. That is the guide's own pre-rename name; knowledge bases
365
- * seeded before the rename still carry one, and it stays legacy content
366
- * rather than becoming a second managed file.
367
- * - NOT ANOTHER PLATFORM FILE. Two platform roles on one path means whichever
368
- * writer runs last wins, silently.
230
+ * - ONE FILE NAME. The name is read at the repository root and nowhere else.
231
+ * - A MARKDOWN NAME. The guide is markdown that agents read as text.
232
+ * - NOT `CLAUDE.md`. That is the guide's own pre-rename name; a knowledge
233
+ * base seeded before the rename may still carry one of its own.
234
+ * - NOT ANOTHER PLATFORM FILE. A read of `access.md` must answer with the
235
+ * access rules, not the guide.
369
236
  * - NOT A ROOT FOLDER'S NAME, compared case-insensitively like the roots are
370
237
  * to each other: the workspaces live on case-insensitive filesystems, where
371
238
  * a file `Docs.md` and a folder `docs.md` are one entry.
@@ -530,13 +397,13 @@ export function isDefaultKbLayout(layout: KbLayout): boolean {
530
397
  }
531
398
 
532
399
  /**
533
- * Render the layout placeholders a managed template carries —
400
+ * Render the layout placeholders a managed text carries —
534
401
  * `{{knowledgeBaseDir}}`, `{{skillsDir}}`, `{{pluginsDir}}`, `{{agentsFile}}`
535
- * — with the names in effect. The packaged guide and `.bevelignore` are
536
- * written this way so a deployment that renamed its roots hands the agent a
537
- * guide that names the folders it will actually find, and a deployment that
538
- * renamed the guide gets a guide naming the file it lives in. Text without
539
- * placeholders passes through unchanged.
402
+ * — with the names in effect. The agent guide's sections and the packaged
403
+ * `.bevelignore` are written this way so a deployment that renamed its roots
404
+ * hands the agent a guide that names the folders it will actually find, and
405
+ * one that gave the guide a name of its own gets a guide naming it. Text
406
+ * without placeholders passes through unchanged.
540
407
  */
541
408
  export function renderKbLayoutPlaceholders(text: string, layout: KbLayout): string {
542
409
  // Replacer FUNCTIONS: a string replacement would interpret `$&`, `$$` and