@owlmeans/viable-common 0.0.32 → 0.0.34

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 (91) hide show
  1. package/README.md +3 -3
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/agent-presentation/SKILL.md +1 -1
  4. package/agent-meta/skills/viable-common/SKILL.md +109 -57
  5. package/build/ba/index.d.ts +0 -1
  6. package/build/ba/index.d.ts.map +1 -1
  7. package/build/ba/index.js +0 -1
  8. package/build/ba/index.js.map +1 -1
  9. package/build/blueprint/case.js +3 -3
  10. package/build/blueprint/consts.d.ts +38 -0
  11. package/build/blueprint/consts.d.ts.map +1 -1
  12. package/build/blueprint/consts.js +40 -0
  13. package/build/blueprint/consts.js.map +1 -1
  14. package/build/blueprint/index.d.ts +0 -3
  15. package/build/blueprint/index.d.ts.map +1 -1
  16. package/build/blueprint/index.js +0 -3
  17. package/build/blueprint/index.js.map +1 -1
  18. package/build/blueprint/types.d.ts +5 -5
  19. package/build/connect/errors.d.ts +23 -0
  20. package/build/connect/errors.d.ts.map +1 -1
  21. package/build/connect/errors.js +50 -0
  22. package/build/connect/errors.js.map +1 -1
  23. package/build/intent/consts.d.ts +57 -0
  24. package/build/intent/consts.d.ts.map +1 -0
  25. package/build/intent/consts.js +58 -0
  26. package/build/intent/consts.js.map +1 -0
  27. package/build/intent/entrypoints.d.ts +27 -0
  28. package/build/intent/entrypoints.d.ts.map +1 -0
  29. package/build/intent/entrypoints.js +28 -0
  30. package/build/intent/entrypoints.js.map +1 -0
  31. package/build/intent/errors.d.ts +20 -0
  32. package/build/intent/errors.d.ts.map +1 -0
  33. package/build/intent/errors.js +28 -0
  34. package/build/intent/errors.js.map +1 -0
  35. package/build/intent/flow.d.ts +17 -0
  36. package/build/intent/flow.d.ts.map +1 -0
  37. package/build/intent/flow.js +65 -0
  38. package/build/intent/flow.js.map +1 -0
  39. package/build/intent/index.d.ts +7 -0
  40. package/build/intent/index.d.ts.map +1 -0
  41. package/build/intent/index.js +6 -0
  42. package/build/intent/index.js.map +1 -0
  43. package/build/intent/schemas.d.ts +10 -0
  44. package/build/intent/schemas.d.ts.map +1 -0
  45. package/build/intent/schemas.js +24 -0
  46. package/build/intent/schemas.js.map +1 -0
  47. package/build/intent/types.d.ts +40 -0
  48. package/build/intent/types.d.ts.map +1 -0
  49. package/build/intent/types.js +2 -0
  50. package/build/intent/types.js.map +1 -0
  51. package/build/skills/catalogue.d.ts.map +1 -1
  52. package/build/skills/catalogue.js +15 -1
  53. package/build/skills/catalogue.js.map +1 -1
  54. package/build/skills/consts.d.ts +0 -34
  55. package/build/skills/consts.d.ts.map +1 -1
  56. package/build/skills/consts.js +0 -106
  57. package/build/skills/consts.js.map +1 -1
  58. package/build/skills/index.d.ts +0 -1
  59. package/build/skills/index.d.ts.map +1 -1
  60. package/build/skills/index.js +0 -1
  61. package/build/skills/index.js.map +1 -1
  62. package/build/skills/roles.d.ts +0 -9
  63. package/build/skills/roles.d.ts.map +1 -1
  64. package/build/skills/roles.js +0 -226
  65. package/build/skills/roles.js.map +1 -1
  66. package/package.json +18 -8
  67. package/src/ba/index.ts +0 -1
  68. package/src/blueprint/consts.ts +40 -0
  69. package/src/blueprint/index.ts +0 -3
  70. package/src/blueprint/types.ts +5 -5
  71. package/src/connect/errors.ts +55 -0
  72. package/src/intent/consts.ts +66 -0
  73. package/src/intent/entrypoints.ts +43 -0
  74. package/src/intent/errors.ts +33 -0
  75. package/src/intent/flow.ts +67 -0
  76. package/src/intent/index.ts +6 -0
  77. package/src/intent/schemas.ts +27 -0
  78. package/src/intent/types.ts +45 -0
  79. package/src/skills/consts.ts +0 -112
  80. package/src/skills/index.ts +0 -1
  81. package/src/skills/roles.ts +0 -267
  82. package/tests/blueprint.spec.ts +2 -66
  83. package/tests/connect-errors.spec.ts +31 -1
  84. package/tests/error-status.spec.ts +6 -1
  85. package/tests/intent.spec.ts +121 -0
  86. package/src/ba/schemas.ts +0 -196
  87. package/src/blueprint/case.ts +0 -320
  88. package/src/blueprint/resolve.ts +0 -136
  89. package/src/blueprint/schemas.ts +0 -44
  90. package/src/skills/catalogue.ts +0 -1850
  91. package/tests/skills.spec.ts +0 -110
@@ -1,5 +1,3 @@
1
- import type { PromptPolicy } from '@owlmeans/llm-common'
2
- import { ViableSkill } from './consts.js'
3
1
 
4
2
  /**
5
3
  * Who the model is being asked to be.
@@ -46,268 +44,3 @@ export enum ViablePersona {
46
44
  DeclarationLookup = 'declaration-lookup',
47
45
  FixAgent = 'fix-agent',
48
46
  }
49
-
50
- /** Shared by the two helpers that reason about domain models and shared types. */
51
- const DOMAIN_ARCHITECT = `
52
- You are a fullstack web developer architect specializing in state management, domain models,
53
- and typescript.
54
- `.trim()
55
-
56
- /** The rules every coder and the fixer need, whatever they are writing. */
57
- const CODING_BASE = [ViableSkill.OutputSourceOnly, ViableSkill.TsStyle, ViableSkill.TsImports]
58
-
59
- /**
60
- * The frontend stack, in the order a component is built up.
61
- *
62
- * `OwlmeansState` is here, not only on the state coder: the view models that READ the stores are
63
- * written by the view and nav coders and repaired by the fixer, and none of them carried it —
64
- * the traces show `useStoreList(...)` read as `.list` / `.items` / a plain `T[]` in 18 projects,
65
- * the second most frequent repair the fixer made.
66
- */
67
- const FRONTEND = [
68
- ViableSkill.ReactComponents, ViableSkill.OwlMeansEntrypoints, ViableSkill.OwlMeansNav,
69
- ViableSkill.OwlMeansServices, ViableSkill.ShadcnUi, ViableSkill.FormFeedback,
70
- ViableSkill.OwlmeansState, ViableSkill.StoreAccess, ViableSkill.ViewModelNaming,
71
- ViableSkill.LibrariesUi,
72
- ]
73
-
74
- /** The backend stack. */
75
- const BACKEND = [
76
- ViableSkill.ProjectLayout,
77
- ViableSkill.ResourceLayer, ViableSkill.ResourceResults, ViableSkill.ResourceMigrations,
78
- ViableSkill.OwlMeansEntrypoints, ViableSkill.OwlMeansServer, ViableSkill.OwlmeansContext,
79
- ViableSkill.OwlMeansServices, ViableSkill.LibrariesBackend,
80
- ]
81
-
82
- const persona = (role: string, skills: ViableSkill[]): PromptPolicy =>
83
- ({ role: role.trim(), skills })
84
-
85
- /**
86
- * The role text and skill set behind every helper.
87
- *
88
- * This is the single place a persona is written down. Before it existed the same
89
- * sentences lived in thirteen helper files and seven near-identical coder factories, and
90
- * the Express-5, resource-layer and client-state rules were duplicated across three of them.
91
- */
92
- export const VIABLE_PERSONAS: Record<ViablePersona, PromptPolicy> = {
93
-
94
- // --- Analysis and design ---
95
-
96
- [ViablePersona.BusinessAnalyst]: persona(`
97
- You are a professional business analyst and product manager specializing in figuring out
98
- user stories from requirements.
99
- `, [ViableSkill.OutputTextOnly, ViableSkill.MainFlowFocus]),
100
-
101
- [ViablePersona.ProductArchitect]: persona(`
102
- You are a product architect. You read what somebody wants built and say which KIND of
103
- application it is, out of a fixed list. You choose the least capable kind that can deliver
104
- what was asked for, because every capability above it is machinery a real user pays for.
105
- You never stretch a description to fit a more interesting answer.
106
- `, [ViableSkill.OutputTextOnly]),
107
-
108
- [ViablePersona.ProductNaming]: persona(`
109
- You are a professional product and brand manager specializing in naming.
110
- `, [ViableSkill.OutputTextOnly, ViableSkill.MainFlowFocus]),
111
-
112
- [ViablePersona.UxDesigner]: persona(`
113
- You are a professional UX designer specializing in user flow and interaction in web
114
- applications.
115
- This application serves four audiences — anonymous visitors, signed-in end users, the owner,
116
- and staff running the business process. Every screen you design belongs to exactly one of them,
117
- and you are told which one with each task.
118
- `, [ViableSkill.OutputTextOnly]),
119
-
120
- [ViablePersona.VisualDesigner]: persona(`
121
- You are a visual ui designer specializing in writing design guidelines and design systems
122
- for web applications, and your interfaces are calm, precise and unmistakably the product's own.
123
- Your house style: a white ground in light mode and a black one in dark mode; colour from ONE
124
- product accent and never from backgrounds; depth only from hairlines, neutral tiles and a single
125
- floating shadow; a heavy grotesk display face with tight tracking and generous whitespace carrying
126
- the brand; motion that is small and belongs to the picture. You never reach for gradients, glass,
127
- blur or glow, and the older decoration classes a project still defines are painted flat and are
128
- never your tools. You change any of this only when the customer's specification explicitly asks —
129
- it names a background colour, a dark-only or coloured theme, a brand palette, or gradients. The
130
- product's subject matter is not such a request: a bakery is not a warm background, a bank is not a
131
- blue one. What makes a design this product's is its accent, its type and its illustration — never
132
- an added effect.
133
- This application serves four audiences — anonymous visitors, signed-in end users, the owner,
134
- and staff running the business process. Every screen you style belongs to exactly one of them,
135
- and you are told which one with each task.
136
- `, [ViableSkill.OutputTextOnly]),
137
-
138
- // --- Architects ---
139
-
140
- [ViablePersona.LayoutArchitect]: persona(`
141
- You are a frontend developer architect working with typescript and react.
142
- You are specializing in screen layouts and their components.
143
- This application's layouts are the four AREA shells it already ships — guest, user, admin and
144
- operator — so you compose screens to sit inside one of them and you restyle a shell when it
145
- needs to look different. You never write a new layout and never rebuild its navigation.
146
- `, [ViableSkill.LayoutDefinition, ViableSkill.ScreenDefinition, ...FRONTEND]),
147
-
148
- [ViablePersona.ComponentArchitect]: persona(`
149
- You are a frontend developer architect specializing in typescript react components.
150
- `, [...FRONTEND]),
151
-
152
- [ViablePersona.StateArchitect]: persona(`
153
- You are a frontend developer architect specializing in state management and typescript.
154
- `, [ViableSkill.OwlmeansState, ViableSkill.StoreAccess, ViableSkill.ViewModelNaming,
155
- ViableSkill.LibrariesUiState]),
156
-
157
- [ViablePersona.NavigationArchitect]: persona(`
158
- You are a frontend developer architect working with typescript and react.
159
- You are specializing in UI navigation and transitioning.
160
- This application has four fixed AREAS — guest, user (front office), admin and operator (back
161
- office) — and two menu levels inside each: SECTIONS are the top menu, and the screens of the
162
- active section are the side menu. You place a screen by choosing its area and its section, never
163
- by editing a menu component and never by changing an address.
164
- `, [ViableSkill.OwlMeansEntrypoints, ViableSkill.OwlMeansNav, ViableSkill.ScreenDefinition,
165
- ViableSkill.LibrariesUi]),
166
-
167
- [ViablePersona.DomainTypesArchitect]: persona(DOMAIN_ARCHITECT, [ViableSkill.LibrariesCommon]),
168
-
169
- // Not DOMAIN_ARCHITECT. This helper decides the application's entire authorization model — the
170
- // least access each surface can be given, and one name per capability — and borrowing the
171
- // state-management architect's sentence was describing a different job to the model.
172
- [ViablePersona.AccessArchitect]: persona(`
173
- You are a web application security architect specializing in authorization.
174
- You decide the least access an endpoint or a screen can be given and still do its job, and you
175
- name permissions so that one capability has exactly one name across the whole application.
176
- `, [ViableSkill.OwlMeansEntrypoints, ViableSkill.PermissionModel, ViableSkill.LibrariesCommon]),
177
-
178
- [ViablePersona.ApiArchitect]: persona(`
179
- You are an expert backend architect specializing in designing RESTful APIs using
180
- TypeScript and the OwlMeans entrypoint framework.
181
- `, [ViableSkill.OwlMeansEntrypoints, ViableSkill.OwlMeansServer, ViableSkill.ResourceLayer,
182
- ViableSkill.ResourceResults, ViableSkill.OwlmeansContext, ViableSkill.LibrariesBackend]),
183
-
184
- [ViablePersona.DatalayerArchitect]: persona(`
185
- You are a backend developer architect specializing in TypeScript, PostgreSQL and Node.js,
186
- working with the OwlMeans resource layer over Postgres (@owlmeans/postgres-resource), which
187
- owns the schema.
188
- `, [ViableSkill.ResourceLayer, ViableSkill.ResourceResults, ViableSkill.ResourceMigrations,
189
- ViableSkill.OwlmeansContext, ViableSkill.LibrariesBackend]),
190
-
191
- [ViablePersona.BackendDomainArchitect]: persona(`
192
- You are a backend developer architect specializing in business logic development.
193
- If necessary you describe which integrations to use.
194
- You prefer to use LLM chats models to solve sophisticated problems.
195
- You are always specific when consider some integrations.
196
- You always start from the business logic flow description that needs to be implemented
197
- and focus on it.
198
- `, [ViableSkill.ProjectLayout, ViableSkill.OwlmeansContext, ViableSkill.ResourceResults,
199
- ViableSkill.LibrariesBackend]),
200
-
201
- // --- Coders ---
202
-
203
- // `PermissionModel` because this is the persona that WRITES the gate lines — `applyApiAccess`
204
- // and `applyScreenAccess` both run on it. Knowing where the `@` may and may not appear has to
205
- // reach the hand that types it, not only the one that chose the name.
206
- [ViablePersona.CommonCoder]: persona(`
207
- You are an expert fullstack developer specializing in typescript and domain models.
208
- `, [...CODING_BASE, ViableSkill.OwlMeansEntrypoints, ViableSkill.PermissionModel,
209
- ViableSkill.LibrariesCommon]),
210
-
211
- [ViablePersona.UiStateCoder]: persona(`
212
- You are an expert frontend developer specializing in typescript and state management.
213
- `, [...CODING_BASE, ViableSkill.OwlmeansState, ViableSkill.OwlMeansEntrypoints,
214
- ViableSkill.StoreAccess, ViableSkill.ViewModelNaming, ViableSkill.LibrariesUiState]),
215
-
216
- [ViablePersona.UiViewCoder]: persona(`
217
- You are an expert frontend developer specializing in typescript, html, css, and react
218
- development of visual components.
219
- `, [...CODING_BASE, ...FRONTEND]),
220
-
221
- [ViablePersona.UiNavCoder]: persona(`
222
- You are an expert frontend developer specializing in typescript, html, css, and react
223
- development of navigation and transitions.
224
- `, [...CODING_BASE, ...FRONTEND]),
225
-
226
- [ViablePersona.DatalayerCoder]: persona(`
227
- You are an expert fullstack developer specializing in typescript, node.js and the OwlMeans
228
- resource layer over Postgres (@owlmeans/postgres-resource).
229
- `, [...CODING_BASE, ...BACKEND]),
230
-
231
- [ViablePersona.ApiCoder]: persona(`
232
- You are an expert fullstack developer specializing in typescript, node.js, the OwlMeans
233
- entrypoint framework and its resource layer over Postgres (@owlmeans/postgres-resource).
234
- `, [...CODING_BASE, ...BACKEND]),
235
-
236
- [ViablePersona.BackendModelCoder]: persona(`
237
- You are an expert backend developer specializing in typescript and node.js.
238
- You focus on business logic and integrations implementation — you really implement them,
239
- not just mock them.
240
- `, [...CODING_BASE, ViableSkill.ProjectLayout, ViableSkill.OwlmeansContext,
241
- ViableSkill.ResourceResults, ViableSkill.LibrariesBackend]),
242
-
243
- [ViablePersona.RuntimeArchitect]: persona(`
244
- You are a pragmatic backend architect. You decide what a feature needs to RUN, and your
245
- default answer is "a request handler and a table". You reach for a queue, a separate worker
246
- process or an LLM agent only when the work cannot honestly be done inside a request, and you
247
- say plainly which it is when the answer is none of them.
248
- `, [ViableSkill.QueueDiscipline, ViableSkill.ProjectLayout, ViableSkill.OwlmeansContext]),
249
-
250
- [ViablePersona.WorkerCoder]: persona(`
251
- You are an expert backend developer specializing in typescript, queue processing and LLM
252
- agents. You write processors that are safe to run twice, because a worker can die mid-job.
253
- `, [...CODING_BASE, ViableSkill.ProjectLayout, ViableSkill.OwlmeansContext,
254
- ViableSkill.WorkerJobs, ViableSkill.TargetAgents, ViableSkill.ResourceResults,
255
- ViableSkill.LibrariesBackend]),
256
-
257
- [ViablePersona.FreeFlightCoder]: persona(`
258
- You are an expert fullstack developer specializing in typescript.
259
- `, [...CODING_BASE, ...FRONTEND, ...BACKEND, ViableSkill.PermissionModel]),
260
-
261
- // --- Everything else ---
262
-
263
- [ViablePersona.Fixer]: persona(`
264
- You are an expert typescript fullstack developer specializing in bugfixing of web
265
- applications. You change as little as the reported error requires.
266
- `, [...CODING_BASE, ...FRONTEND, ...BACKEND, ViableSkill.FixerHeuristics,
267
- ViableSkill.PermissionModel]),
268
-
269
- // `OwlMeansEntrypoints` is what makes this persona able to ROUTE a report: the four contract
270
- // files, and which of them a symptom belongs to. Without it a runtime `entrypoint.call is not a
271
- // function` reads as a broken component, and the orchestrator sends a coder to rewrite the view
272
- // model that is already correct.
273
- //
274
- // `OwlMeansServices` is here for the same reason one level down: this persona WRITES the
275
- // instruction the coder follows, so it is the one that decides a `Service not found` is
276
- // repaired by naming a different alias. It has to know which registry that name comes from
277
- // before it delegates, or the coder inherits the wrong premise along with the task.
278
- [ViablePersona.Orchestrator]: persona(`
279
- You are a senior fullstack typescript coding agent. You are orchestrating the
280
- development. Call the respective tools to inspect the project and to delegate the coding.
281
- `, [ViableSkill.ProjectLayout, ViableSkill.AgentTooling, ViableSkill.OwlmeansContext,
282
- ViableSkill.OwlMeansEntrypoints, ViableSkill.OwlMeansServices, ViableSkill.PermissionModel,
283
- ViableSkill.LayoutDefinition, ViableSkill.ScreenDefinition, ViableSkill.TsImports]),
284
-
285
- [ViablePersona.SourceExtractor]: persona(`
286
- You are a code-context extraction agent. You are NOT writing code. Do not summarise.
287
- Your only deliverable is the tool call selecting the relevant line ranges. Be frugal —
288
- every extra line costs the coder context.
289
- `, []),
290
-
291
- [ViablePersona.DeclarationLookup]: persona(`
292
- You are a TypeScript type-declaration lookup agent. You are NOT writing code and NOT
293
- fixing anything. Your only deliverable is the report_declarations tool call naming, for
294
- each requested type, the file that declares it. Be frugal — every tool call costs budget,
295
- and a wrong path is worse than an unresolved name.
296
- `, []),
297
-
298
- [ViablePersona.FixAgent]: persona(`
299
- You are a build-repair agent working on a TypeScript monorepo you can inspect with tools.
300
-
301
- You are called only after a scripted fix loop has already failed on this error, so the
302
- obvious edit has been tried and did not work. Investigate before you edit: read the file
303
- the error points at, look up the authoritative name in the registry, search for the other
304
- places a name is spelled. A build error that keeps coming back is almost always a name
305
- that exists in two spellings or one that was never declared — declare or create the
306
- missing thing at its registered name; renaming a reference to a new guess is what made
307
- the loop circle.
308
-
309
- Make the smallest edit that addresses the cause, verify with the build, and finish with
310
- report_fix. Never delete a feature to silence an error, and never stub a handler to make
311
- the build pass.
312
- `, []),
313
- }
@@ -1,13 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import {
3
- applyBlueprintCase, BLUEPRINT_CASES, BlueprintCase, landingGatePreferenceOf, LandingGatePreference,
4
- personaSkillsOf,
5
- } from '../src/blueprint/index.js'
2
+ import { landingGatePreferenceOf, LandingGatePreference } from '../src/blueprint/index.js'
6
3
  import type { Blueprint } from '../src/blueprint/index.js'
7
4
  import { ViableSkill } from '../src/skills/consts.js'
8
5
  import { ViablePersona } from '../src/skills/roles.js'
9
6
 
10
- /** Just enough of a blueprint for a case to be applied to — the shape a test builds. */
7
+ /** Just enough of a blueprint to read a preference from — the shape a test builds. */
11
8
  const base = (extra: Partial<Blueprint> = {}): Blueprint => ({
12
9
  id: 'test-blueprint',
13
10
  title: 'Test',
@@ -34,65 +31,4 @@ describe('viable-common - the landing-gate preference', () => {
34
31
  expect(landingGatePreferenceOf({ experience: { landingGate: LandingGatePreference.Discourage } }))
35
32
  .toBe(LandingGatePreference.Discourage)
36
33
  })
37
-
38
- test('each case weighs the gate: web and the AI kinds encourage, scalable allows, a game discourages', () => {
39
- const preferences = Object.fromEntries(Object.values(BlueprintCase).map(id =>
40
- [id, landingGatePreferenceOf(applyBlueprintCase(base(), id))]))
41
-
42
- expect(preferences).toEqual({
43
- [BlueprintCase.Web]: LandingGatePreference.Encourage,
44
- [BlueprintCase.Scalable]: LandingGatePreference.Allow,
45
- [BlueprintCase.AiPipeline]: LandingGatePreference.Encourage,
46
- [BlueprintCase.AiAgent]: LandingGatePreference.Encourage,
47
- [BlueprintCase.Game]: LandingGatePreference.Discourage,
48
- })
49
- })
50
-
51
- test('a case overrides the base layer key by key, and no case leaves a blueprint without one', () => {
52
- const allowing = base({ experience: { landingGate: LandingGatePreference.Allow } })
53
-
54
- expect(landingGatePreferenceOf(applyBlueprintCase(allowing, BlueprintCase.Game)))
55
- .toBe(LandingGatePreference.Discourage)
56
- expect(applyBlueprintCase(base(), undefined).experience).toBeUndefined()
57
- expect(Object.isFrozen(applyBlueprintCase(base(), BlueprintCase.Web).experience)).toBe(true)
58
- })
59
- })
60
-
61
- describe('viable-common - the landing-gate skill in the cases', () => {
62
- const gatePersonas = [
63
- ViablePersona.BusinessAnalyst, ViablePersona.UxDesigner, ViablePersona.LayoutArchitect,
64
- ViablePersona.ComponentArchitect, ViablePersona.UiViewCoder, ViablePersona.UiNavCoder,
65
- ]
66
-
67
- test('reaches the analyst and the frontend personas of every encouraging case, and nobody else', () => {
68
- for (const id of [BlueprintCase.Web, BlueprintCase.AiPipeline, BlueprintCase.AiAgent]) {
69
- const cased = applyBlueprintCase(base(), id)
70
- for (const persona of gatePersonas) {
71
- expect(personaSkillsOf(cased, persona), `${id} → ${persona}`).toContain(ViableSkill.LandingGate)
72
- }
73
- expect(personaSkillsOf(cased, ViablePersona.ApiCoder)).not.toContain(ViableSkill.LandingGate)
74
- }
75
- for (const id of [BlueprintCase.Scalable, BlueprintCase.Game]) {
76
- const cased = applyBlueprintCase(base(), id)
77
- for (const persona of gatePersonas) {
78
- expect(personaSkillsOf(cased, persona), `${id} → ${persona}`).not.toContain(ViableSkill.LandingGate)
79
- }
80
- }
81
- })
82
-
83
- test('is merged with what a case and the base already give a persona, never in place of it', () => {
84
- const agent = applyBlueprintCase(base(), BlueprintCase.AiAgent)
85
-
86
- expect(personaSkillsOf(agent, ViablePersona.BusinessAnalyst)).toEqual([
87
- ViableSkill.MainFlowFocus, ViableSkill.AgenticChoice, ViableSkill.LandingGate,
88
- ])
89
- expect(personaSkillsOf(agent, ViablePersona.UxDesigner))
90
- .toEqual([ViableSkill.AgenticChoice, ViableSkill.LandingGate])
91
- expect(personaSkillsOf(agent, ViablePersona.WorkerCoder)).toEqual([
92
- ViableSkill.AgenticChoice, ViableSkill.TargetLlm, ViableSkill.LibrariesAi,
93
- ViableSkill.TargetAgentTools,
94
- ])
95
- expect(BLUEPRINT_CASES[BlueprintCase.AiPipeline].patch.packages?.personaSkills?.[ViablePersona.BusinessAnalyst])
96
- .toEqual([ViableSkill.AgenticChoice, ViableSkill.LandingGate])
97
- })
98
34
  })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
  import { ResilientError } from '@owlmeans/error'
3
- import { ConnectOutOfCredits } from '../src/connect/errors.js'
3
+ import { ConnectConsentRequired, ConnectOutOfCredits } from '../src/connect/errors.js'
4
4
 
5
5
  describe('@owlmeans/viable-common — ConnectOutOfCredits', () => {
6
6
  test('carries its fields fresh, and survives a marshal/unmarshal round trip', () => {
@@ -20,3 +20,33 @@ describe('@owlmeans/viable-common — ConnectOutOfCredits', () => {
20
20
  }))
21
21
  })
22
22
  })
23
+
24
+ describe('@owlmeans/viable-common — ConnectConsentRequired', () => {
25
+ const url = 'https://vib-stage.owlmeans.org/account/billing?consent=1&from=mcp:story'
26
+ const deadline = new Date('2026-10-09T00:00:00.000Z')
27
+
28
+ test('packs gate, deadline and URL, and survives a marshal/unmarshal round trip', () => {
29
+ const fresh = new ConnectConsentRequired(ConnectConsentRequired.encode('story', url, deadline))
30
+
31
+ expect(fresh.message).toBe(`viable-connect:consent-required:story:${deadline.getTime()}:${encodeURIComponent(url)}`)
32
+ expect(fresh.gate).toBe('story')
33
+ expect(fresh.deadline?.toISOString()).toBe(deadline.toISOString())
34
+ expect(fresh.consentUrl).toBe(url)
35
+ expect((fresh.constructor as { httpStatus?: number }).httpStatus).toBe(428)
36
+
37
+ const restored = ResilientError.ensure(fresh.marshal())
38
+
39
+ expect(restored).toBeInstanceOf(ConnectConsentRequired)
40
+ expect(restored).toEqual(expect.objectContaining({ gate: 'story', consentUrl: url }))
41
+ expect((restored as ConnectConsentRequired).deadline?.toISOString()).toBe(deadline.toISOString())
42
+ })
43
+
44
+ test('an unknown deadline travels as 0 and reads back as absent', () => {
45
+ const fresh = new ConnectConsentRequired(ConnectConsentRequired.encode('create', url))
46
+
47
+ expect(fresh.message).toContain(':create:0:')
48
+ expect(fresh.deadline).toBeUndefined()
49
+ expect(fresh.consentUrl).toBe(url)
50
+ expect((ResilientError.ensure(fresh.marshal()) as ConnectConsentRequired).deadline).toBeUndefined()
51
+ })
52
+ })
@@ -18,6 +18,7 @@ const DECLARED: Record<string, number | undefined> = {
18
18
  LocalSlotUnsupported: 409,
19
19
  ConnectOpRefused: 422,
20
20
  ConnectOutOfCredits: 402,
21
+ ConnectConsentRequired: 428,
21
22
  ConnectOpTimeout: undefined,
22
23
  ProjectResourceError: undefined,
23
24
  ProjectNotFound: 404,
@@ -40,7 +41,7 @@ describe('viable-common refusals — declared HTTP statuses', () => {
40
41
  expect(classes.map(([name]) => name).sort()).toEqual(Object.keys(DECLARED).sort())
41
42
  })
42
43
 
43
- test('balance 402, an absent target 404, a conflicting state 409, refused content 422; faults nothing', () => {
44
+ test('balance 402, an absent target 404, a conflicting state 409, refused content 422, a consent only a person gives 428; faults nothing', () => {
44
45
  for (const [name, Class] of classes) {
45
46
  expect([name, statusOf(new Class('x'))]).toEqual([name, DECLARED[name]])
46
47
  }
@@ -55,5 +56,9 @@ describe('viable-common refusals — declared HTTP statuses', () => {
55
56
  new connect.ConnectOutOfCredits(connect.ConnectOutOfCredits.encode('story', 1, 0, 'https://x.test')).marshal()
56
57
  )
57
58
  expect([statusOf(credits), (credits as connect.ConnectOutOfCredits).gate]).toEqual([402, 'story'])
59
+ const consent = ResilientError.ensure(
60
+ new connect.ConnectConsentRequired(connect.ConnectConsentRequired.encode('story', 'https://x.test/?consent=1')).marshal()
61
+ )
62
+ expect([statusOf(consent), (consent as connect.ConnectConsentRequired).consentUrl]).toEqual([428, 'https://x.test/?consent=1'])
58
63
  })
59
64
  })
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import addFormats from 'ajv-formats'
4
+ import { DISPATCHER } from '@owlmeans/auth'
5
+ import { HOME } from '@owlmeans/context'
6
+ import { aliasOf, protocols } from '@owlmeans/entrypoint'
7
+ import { makeFlowModel } from '@owlmeans/flow'
8
+ import { AppType } from '@owlmeans/context'
9
+ import { RouteMethod } from '@owlmeans/route'
10
+ import {
11
+ INTENT_PAYLOAD_REF, INTENT_PROMPT_MAX, INTENT_TTL_SECONDS, IntentExpired, IntentFlowStep,
12
+ IntentPickupBodySchema, IntentStashBodySchema, IntentThrottled, intent, intentFlow, makeIntentProtocols,
13
+ } from '../src/intent/index.js'
14
+
15
+ const ajv = new Ajv({ allErrors: true, strict: false })
16
+ addFormats(ajv)
17
+
18
+ // The platform mints references with `createIdOfLength(24)` (Base58); mirrored here so the spec
19
+ // needs no dependency beyond the package's own.
20
+ const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
21
+ const createIdOfLength = (length: number): string =>
22
+ Array.from({ length }, () => BASE58[Math.floor(Math.random() * BASE58.length)]).join('')
23
+
24
+ describe('@owlmeans/viable-common — intent-first hand-off', () => {
25
+ const tree = makeIntentProtocols()
26
+
27
+ test('declares one guest base, two guest routes and a landing screen — no guard, no gate, no service', () => {
28
+ expect(tree.base.alias).toBe(intent.base)
29
+ expect(tree.stash.alias).toBe(intent.stash)
30
+ expect(tree.pickup.alias).toBe(intent.pickup)
31
+ expect(tree.landing.alias).toBe(intent.landing)
32
+ expect(new Set(protocols(tree).map(protocol => protocol.alias)).size).toBe(4)
33
+ for (const declaration of [tree.base, tree.stash, tree.pickup, tree.landing]) {
34
+ expect(declaration.guards ?? []).toEqual([])
35
+ expect(declaration.gate).toBeUndefined()
36
+ expect(declaration.route.route.service).toBeUndefined()
37
+ }
38
+ expect(tree.stash.route.route.path).toBe('/')
39
+ expect(tree.stash.route.route.method).toBe(RouteMethod.POST)
40
+ expect(tree.pickup.route.route.path).toBe('/pickup')
41
+ expect(tree.landing.route.route.path).toBe('/start')
42
+ expect(tree.landing.route.route.type).toBe(AppType.Frontend)
43
+ expect(aliasOf(tree.stash)).toBe(intent.stash)
44
+ })
45
+
46
+ test('a stash needs a non-empty bounded prompt AND the confirmation, and nothing else', () => {
47
+ const validate = ajv.compile(IntentStashBodySchema)
48
+ expect(validate({ prompt: 'A booking app', consent: true })).toBe(true)
49
+ expect(validate({ prompt: 'A booking app' })).toBe(false)
50
+ expect(validate({ prompt: 'A booking app', consent: false })).toBe(false)
51
+ expect(validate({ prompt: '', consent: true })).toBe(false)
52
+ expect(validate({ prompt: 'x'.repeat(INTENT_PROMPT_MAX), consent: true })).toBe(true)
53
+ expect(validate({ prompt: 'x'.repeat(INTENT_PROMPT_MAX + 1), consent: true })).toBe(false)
54
+ expect(validate({ prompt: 'A booking app', consent: true, extra: 1 })).toBe(false)
55
+ })
56
+
57
+ test('a pickup reference is exactly what createIdOfLength(24) makes, and nothing that could be a cache key trick', () => {
58
+ const validate = ajv.compile(IntentPickupBodySchema)
59
+ for (let i = 0; i < 50; i++) {
60
+ expect(validate({ ref: createIdOfLength(24) })).toBe(true)
61
+ }
62
+ expect(validate({ ref: 'short' })).toBe(false)
63
+ expect(validate({ ref: `${createIdOfLength(23)}:` })).toBe(false)
64
+ expect(validate({ ref: `intent:${createIdOfLength(17)}` })).toBe(false)
65
+ expect(validate({ ref: `${createIdOfLength(23)}*` })).toBe(false)
66
+ expect(validate({ ref: `${createIdOfLength(23)}0` })).toBe(false)
67
+ expect(validate({ ref: createIdOfLength(24), extra: 1 })).toBe(false)
68
+ })
69
+
70
+ test('the site half: compose → handoff lands on the platform screen with the reference as payload', async () => {
71
+ const ref = createIdOfLength(24)
72
+ const model = await makeFlowModel(intentFlow)
73
+ expect(model.state().step).toBe(IntentFlowStep.Compose)
74
+
75
+ model.transit('handoff', true, { [INTENT_PAYLOAD_REF]: ref })
76
+
77
+ expect(model.state().step).toBe(IntentFlowStep.Land)
78
+ expect(model.step().module).toBe(intent.landing)
79
+ expect(model.payload()).toEqual({ [INTENT_PAYLOAD_REF]: ref })
80
+ })
81
+
82
+ test('the landing half, signed in: enter(land) → review resolves to HOME', async () => {
83
+ const model = await makeFlowModel(intentFlow)
84
+ model.enter(IntentFlowStep.Land).updatePayload({ [INTENT_PAYLOAD_REF]: createIdOfLength(24) })
85
+ expect(model.next().step).toBe(IntentFlowStep.Review)
86
+
87
+ model.transit('review', true)
88
+ expect(model.step().module).toBe(HOME)
89
+ })
90
+
91
+ test('the landing half, signed out: sign-in is explicit, and from sign-in the way forward is HOME (what suspendFlow persists)', async () => {
92
+ const model = await makeFlowModel(intentFlow)
93
+ model.enter(IntentFlowStep.Land).updatePayload({ [INTENT_PAYLOAD_REF]: createIdOfLength(24) })
94
+
95
+ // `next()` must never pick the sign-in edge on its own.
96
+ expect(model.transitions(true).map(t => t.transition)).toEqual(['sign-in'])
97
+ expect(model.next().transition).toBe('review')
98
+
99
+ model.transit('sign-in', true)
100
+ expect(model.step().module).toBe(DISPATCHER)
101
+ // `suspendFlow` stores `model.step(model.next().step).module` — the home screen.
102
+ const forward = model.next()
103
+ expect(forward.step).toBe(IntentFlowStep.Review)
104
+ expect(model.step(forward.step).module).toBe(HOME)
105
+ })
106
+
107
+ test('the serialized token round-trips the reference (the site never sends it, but the model must survive it)', async () => {
108
+ const ref = createIdOfLength(24)
109
+ const model = await makeFlowModel(intentFlow)
110
+ const token = model.transit('handoff', true, { [INTENT_PAYLOAD_REF]: ref })
111
+ expect(typeof token).toBe('string')
112
+ expect(token.length).toBeGreaterThan(0)
113
+ })
114
+
115
+ test('the errors answer the statuses the wire depends on', () => {
116
+ expect((IntentExpired as unknown as { httpStatus: number }).httpStatus).toBe(404)
117
+ expect((IntentThrottled as unknown as { httpStatus: number }).httpStatus).toBe(429)
118
+ expect(new IntentExpired().message).toContain('expired')
119
+ expect(INTENT_TTL_SECONDS).toBe(120)
120
+ })
121
+ })